diff --git a/src/main/java/com/apcsa/spacetracker/controller/MissionController.java b/src/main/java/com/apcsa/spacetracker/controller/MissionController.java new file mode 100644 index 0000000..7ca129b --- /dev/null +++ b/src/main/java/com/apcsa/spacetracker/controller/MissionController.java @@ -0,0 +1,36 @@ +package com.apcsa.spacetracker.controller; + +import com.apcsa.spacetracker.service.MissionService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +public class MissionController { + private final MissionService missionService; + + public MissionController(MissionService missionService) { + this.missionService = missionService; + } + + @GetMapping("/missions") + public String missions( + @RequestParam(required = false) String agency, + @RequestParam(required = false) String status, + @RequestParam(required = false) Integer year, + Model model + ) { + try { + model.addAttribute("missions", missionService.getUpcomingMissions(agency, status, year)); + } catch (Exception ex) { + model.addAttribute("error", "Could not load mission data right now."); + } + + model.addAttribute("agency", agency == null ? "" : agency); + model.addAttribute("status", status == null ? "" : status); + model.addAttribute("year", year == null ? "" : year); + + return "missions"; + } +} diff --git a/src/main/java/com/apcsa/spacetracker/controller/SkyController.java b/src/main/java/com/apcsa/spacetracker/controller/SkyController.java new file mode 100644 index 0000000..7d7c83e --- /dev/null +++ b/src/main/java/com/apcsa/spacetracker/controller/SkyController.java @@ -0,0 +1,64 @@ +package com.apcsa.spacetracker.controller; + +import com.apcsa.spacetracker.service.GeocodingService; +import com.apcsa.spacetracker.service.SkyService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Controller +public class SkyController { + private final SkyService skyService; + private final GeocodingService geocodingService; + + public SkyController(SkyService skyService, GeocodingService geocodingService) { + this.skyService = skyService; + this.geocodingService = geocodingService; + } + + @GetMapping("/sky") + public String sky( + @RequestParam(required = false) Double latitude, + @RequestParam(required = false) Double longitude, + @RequestParam(required = false) String address, + @RequestParam(required = false) LocalDate date, + @RequestParam(required = false) LocalTime time, + Model model + ) { + LocalDate resolvedDate = date == null ? LocalDate.now() : date; + LocalTime resolvedTime = time == null ? LocalTime.now().withSecond(0).withNano(0) : time; + + Double resolvedLat = latitude; + Double resolvedLon = longitude; + + if ((resolvedLat == null || resolvedLon == null) && address != null && !address.isBlank()) { + GeocodingService.Coordinates coords = geocodingService.geocodeAddress(address); + if (coords != null) { + resolvedLat = coords.getLatitude(); + resolvedLon = coords.getLongitude(); + } else { + model.addAttribute("error", "Could not find that address."); + } + } + + if (resolvedLat != null && resolvedLon != null) { + try { + model.addAttribute("objects", skyService.getSkyObjects(resolvedLat, resolvedLon, resolvedDate, resolvedTime)); + } catch (Exception ex) { + model.addAttribute("error", "Could not load sky positions right now."); + } + } + + model.addAttribute("latitude", resolvedLat == null ? "" : resolvedLat); + model.addAttribute("longitude", resolvedLon == null ? "" : resolvedLon); + model.addAttribute("address", address == null ? "" : address); + model.addAttribute("date", resolvedDate); + model.addAttribute("time", resolvedTime); + + return "sky"; + } +} diff --git a/src/main/java/com/apcsa/spacetracker/model/Mission.java b/src/main/java/com/apcsa/spacetracker/model/Mission.java new file mode 100644 index 0000000..276437a --- /dev/null +++ b/src/main/java/com/apcsa/spacetracker/model/Mission.java @@ -0,0 +1,57 @@ +package com.apcsa.spacetracker.model; + +public class Mission { + private final String name; + private final String agency; + private final String status; + private final String launchDate; + private final String location; + private final String missionType; + private final int year; + + public Mission( + String name, + String agency, + String status, + String launchDate, + String location, + String missionType, + int year + ) { + this.name = name; + this.agency = agency; + this.status = status; + this.launchDate = launchDate; + this.location = location; + this.missionType = missionType; + this.year = year; + } + + public String getName() { + return name; + } + + public String getAgency() { + return agency; + } + + public String getStatus() { + return status; + } + + public String getLaunchDate() { + return launchDate; + } + + public String getLocation() { + return location; + } + + public String getMissionType() { + return missionType; + } + + public int getYear() { + return year; + } +} diff --git a/src/main/java/com/apcsa/spacetracker/model/SkyObject.java b/src/main/java/com/apcsa/spacetracker/model/SkyObject.java new file mode 100644 index 0000000..0354640 --- /dev/null +++ b/src/main/java/com/apcsa/spacetracker/model/SkyObject.java @@ -0,0 +1,37 @@ +package com.apcsa.spacetracker.model; + +public class SkyObject { + private final String name; + private final double altitudeDegrees; + private final double azimuthDegrees; + private final String direction; + private final boolean visible; + + public SkyObject(String name, double altitudeDegrees, double azimuthDegrees, String direction, boolean visible) { + this.name = name; + this.altitudeDegrees = altitudeDegrees; + this.azimuthDegrees = azimuthDegrees; + this.direction = direction; + this.visible = visible; + } + + public String getName() { + return name; + } + + public double getAltitudeDegrees() { + return altitudeDegrees; + } + + public double getAzimuthDegrees() { + return azimuthDegrees; + } + + public String getDirection() { + return direction; + } + + public boolean isVisible() { + return visible; + } +} diff --git a/src/main/java/com/apcsa/spacetracker/service/GeocodingService.java b/src/main/java/com/apcsa/spacetracker/service/GeocodingService.java new file mode 100644 index 0000000..5324151 --- /dev/null +++ b/src/main/java/com/apcsa/spacetracker/service/GeocodingService.java @@ -0,0 +1,68 @@ +package com.apcsa.spacetracker.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +@Service +public class GeocodingService { + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + public GeocodingService() { + this.restTemplate = new RestTemplate(); + this.objectMapper = new ObjectMapper(); + } + + public Coordinates geocodeAddress(String address) { + String url = UriComponentsBuilder + .fromHttpUrl("https://nominatim.openstreetmap.org/search") + .queryParam("q", address) + .queryParam("format", "jsonv2") + .queryParam("limit", "1") + .toUriString(); + + try { + String json = restTemplate.getForObject(url, String.class); + if (json == null || json.isBlank()) { + return null; + } + + JsonNode root = objectMapper.readTree(json); + if (!root.isArray() || root.isEmpty()) { + return null; + } + + JsonNode first = root.get(0); + double lat = first.path("lat").asDouble(Double.NaN); + double lon = first.path("lon").asDouble(Double.NaN); + if (Double.isNaN(lat) || Double.isNaN(lon)) { + return null; + } + + return new Coordinates(lat, lon); + } catch (Exception e) { + return null; + } + } + + public static class Coordinates { + private final double latitude; + private final double longitude; + + public Coordinates(double latitude, double longitude) { + this.latitude = latitude; + this.longitude = longitude; + } + + public double getLatitude() { + return latitude; + } + + public double getLongitude() { + return longitude; + } + } +} diff --git a/src/main/java/com/apcsa/spacetracker/service/MissionService.java b/src/main/java/com/apcsa/spacetracker/service/MissionService.java new file mode 100644 index 0000000..fb20a71 --- /dev/null +++ b/src/main/java/com/apcsa/spacetracker/service/MissionService.java @@ -0,0 +1,108 @@ +package com.apcsa.spacetracker.service; + +import com.apcsa.spacetracker.model.Mission; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class MissionService { + private static final String UPCOMING_LAUNCHES_URL = "https://ll.thespacedevs.com/2.3.0/launches/upcoming/?limit=50"; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + public MissionService() { + this.restTemplate = new RestTemplate(); + this.objectMapper = new ObjectMapper(); + } + + public List getUpcomingMissions(String agencyFilter, String statusFilter, Integer yearFilter) { + List missions = fetchUpcomingMissions(); + List filtered = new ArrayList<>(); + + for (Mission mission : missions) { + if (!matchesAgency(mission, agencyFilter)) { + continue; + } + if (!matchesStatus(mission, statusFilter)) { + continue; + } + if (!matchesYear(mission, yearFilter)) { + continue; + } + filtered.add(mission); + } + + return filtered; + } + + private List fetchUpcomingMissions() { + List missions = new ArrayList<>(); + String json = restTemplate.getForObject(UPCOMING_LAUNCHES_URL, String.class); + + if (json == null || json.isBlank()) { + return missions; + } + + try { + JsonNode root = objectMapper.readTree(json); + JsonNode results = root.path("results"); + if (!results.isArray()) { + return missions; + } + + for (JsonNode launch : results) { + String name = launch.path("name").asText("Unknown"); + String agency = launch.path("launch_service_provider").path("name").asText("Unknown"); + String status = launch.path("status").path("name").asText("Unknown"); + String launchDate = launch.path("net").asText("Unknown"); + String location = launch.path("pad").path("location").path("name").asText("Unknown"); + String missionType = launch.path("mission").path("type").asText("Unknown"); + int year = parseYear(launchDate); + + missions.add(new Mission(name, agency, status, launchDate, location, missionType, year)); + } + } catch (Exception e) { + throw new IllegalStateException("Unable to parse Launch Library response", e); + } + + return missions; + } + + private int parseYear(String launchDate) { + if (launchDate == null || launchDate.length() < 4) { + return -1; + } + try { + return Integer.parseInt(launchDate.substring(0, 4)); + } catch (NumberFormatException e) { + return -1; + } + } + + private boolean matchesAgency(Mission mission, String agencyFilter) { + if (agencyFilter == null || agencyFilter.isBlank()) { + return true; + } + return mission.getAgency().toLowerCase().contains(agencyFilter.toLowerCase()); + } + + private boolean matchesStatus(Mission mission, String statusFilter) { + if (statusFilter == null || statusFilter.isBlank()) { + return true; + } + return mission.getStatus().toLowerCase().contains(statusFilter.toLowerCase()); + } + + private boolean matchesYear(Mission mission, Integer yearFilter) { + if (yearFilter == null) { + return true; + } + return mission.getYear() == yearFilter; + } +} diff --git a/src/main/java/com/apcsa/spacetracker/service/SkyService.java b/src/main/java/com/apcsa/spacetracker/service/SkyService.java new file mode 100644 index 0000000..0b4fcd8 --- /dev/null +++ b/src/main/java/com/apcsa/spacetracker/service/SkyService.java @@ -0,0 +1,125 @@ +package com.apcsa.spacetracker.service; + +import com.apcsa.spacetracker.model.SkyObject; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +@Service +public class SkyService { + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final String appId; + private final String appSecret; + + public SkyService( + @Value("${astronomy.api.app-id:}") String appId, + @Value("${astronomy.api.app-secret:}") String appSecret + ) { + this.restTemplate = new RestTemplate(); + this.objectMapper = new ObjectMapper(); + this.appId = appId; + this.appSecret = appSecret; + } + + public List getSkyObjects(double latitude, double longitude, LocalDate date, LocalTime time) { + ensureCredentialsPresent(); + + String url = UriComponentsBuilder + .fromHttpUrl("https://api.astronomyapi.com/api/v2/bodies/positions") + .queryParam("latitude", latitude) + .queryParam("longitude", longitude) + .queryParam("elevation", 0) + .queryParam("from_date", date) + .queryParam("to_date", date) + .queryParam("time", time) + .queryParam("output", "table") + .toUriString(); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Basic " + encodeBasicAuth(appId, appSecret)); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + String body = response.getBody(); + if (body == null || body.isBlank()) { + return List.of(); + } + + return parseSkyObjects(body); + } + + private List parseSkyObjects(String json) { + List objects = new ArrayList<>(); + + try { + JsonNode root = objectMapper.readTree(json); + JsonNode rows = root.path("data").path("table").path("rows"); + if (!rows.isArray()) { + return objects; + } + + for (JsonNode row : rows) { + String name = row.path("entry").path("name").asText("Unknown"); + JsonNode firstCell = row.path("cells").isArray() && !row.path("cells").isEmpty() + ? row.path("cells").get(0) + : null; + + if (firstCell == null) { + continue; + } + + double altitude = firstCell.path("position").path("horizontal").path("altitude").path("degrees").asDouble(Double.NaN); + double azimuth = firstCell.path("position").path("horizontal").path("azimuth").path("degrees").asDouble(Double.NaN); + + if (Double.isNaN(altitude) || Double.isNaN(azimuth)) { + continue; + } + + boolean visible = altitude > 0.0; + String direction = azimuthToDirection(azimuth); + + objects.add(new SkyObject(name, round1(altitude), round1(azimuth), direction, visible)); + } + } catch (Exception e) { + throw new IllegalStateException("Unable to parse Astronomy API response", e); + } + + return objects; + } + + private void ensureCredentialsPresent() { + if (appId == null || appId.isBlank() || appSecret == null || appSecret.isBlank()) { + throw new IllegalStateException("Astronomy API credentials are not configured."); + } + } + + private String encodeBasicAuth(String id, String secret) { + String raw = id + ":" + secret; + return Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + + private double round1(double value) { + return Math.round(value * 10.0) / 10.0; + } + + private String azimuthToDirection(double azimuth) { + String[] dirs = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"}; + int index = (int) Math.round(azimuth / 45.0) % 8; + return dirs[index]; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 063ab83..814352f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -6,3 +6,5 @@ spring.config.import=optional:file:.env[.properties] # Pull from environment variables; safe defaults for local dev. nasa.api.key=${NASA_API_KEY:DEMO_KEY} +astronomy.api.app-id=${ASTRONOMY_API_APP_ID:} +astronomy.api.app-secret=${ASTRONOMY_API_APP_SECRET:} diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 0ef7cbf..c95dbbe 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -89,6 +89,23 @@ font-size: 0.9rem; color: #475569; } + + .links { + margin-top: 14px; + display: flex; + gap: 10px; + flex-wrap: wrap; + } + + .btn { + display: inline-block; + text-decoration: none; + background: var(--accent); + color: #082f49; + font-weight: 700; + padding: 8px 12px; + border-radius: 9px; + } @@ -101,6 +118,11 @@ APCSA Final + +
Could not load NASA APOD right now.
diff --git a/src/main/resources/templates/missions.html b/src/main/resources/templates/missions.html new file mode 100644 index 0000000..c753f93 --- /dev/null +++ b/src/main/resources/templates/missions.html @@ -0,0 +1,193 @@ + + + + + + SpaceTracker Missions + + + +
+
+
+

Upcoming Missions

+

Live data from Launch Library 2

+
+ Home +
+ + + +
+ + + + +
+ +
Could not load mission data right now.
+ + + + + + + + + + + + + + + + + + + + + + +
MissionAgencyStatusLaunch DateLocationType
nameagencystatuslaunchDatelocationtype
+ +

+ No missions matched your filters. +

+
+ + diff --git a/src/main/resources/templates/sky.html b/src/main/resources/templates/sky.html new file mode 100644 index 0000000..b1e305c --- /dev/null +++ b/src/main/resources/templates/sky.html @@ -0,0 +1,225 @@ + + + + + + Sky Position + + + +
+
+
+

Sky Position

+

See where major sky objects are relative to the user.

+
+ +
+ +
+ + + + + + + + + +
+ +
Could not load sky positions right now.
+ + + + + + + + + + + + + + + + + + + + +
ObjectAltitude (deg)Azimuth (deg)DirectionVisible
name0.00.0NYes
+ +

No sky objects returned for current input.

+
+ + + +