feat: add missions and sky-position pages

This commit is contained in:
End 2026-04-14 21:38:38 -07:00
parent b3e5bc6619
commit c7cb55c9a8
No known key found for this signature in database
11 changed files with 937 additions and 0 deletions

View file

@ -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";
}
}

View file

@ -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";
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}
}

View file

@ -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<Mission> getUpcomingMissions(String agencyFilter, String statusFilter, Integer yearFilter) {
List<Mission> missions = fetchUpcomingMissions();
List<Mission> 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<Mission> fetchUpcomingMissions() {
List<Mission> 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;
}
}

View file

@ -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<SkyObject> 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<Void> entity = new HttpEntity<>(headers);
ResponseEntity<String> 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<SkyObject> parseSkyObjects(String json) {
List<SkyObject> 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];
}
}

View file

@ -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:}

View file

@ -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;
}
</style>
</head>
<body>
@ -101,6 +118,11 @@
<span class="badge">APCSA Final</span>
</div>
<div class="links">
<a class="btn" href="/missions">View Missions</a>
<a class="btn" href="/sky">Sky Position</a>
</div>
<div class="error" th:if="${error}" th:text="${error}">Could not load NASA APOD right now.</div>
<section class="apod" th:if="${apod != null}">

View file

@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SpaceTracker Missions</title>
<style>
:root {
--bg1: #02111f;
--bg2: #0d2e4d;
--card: #ffffff;
--text: #0f172a;
--muted: #475569;
--line: #dbeafe;
--accent: #38bdf8;
--error-bg: #fef2f2;
--error-text: #991b1b;
--error-line: #fecaca;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
background: radial-gradient(circle at top, #0f3b62, var(--bg1) 60%);
min-height: 100vh;
color: var(--text);
padding: 24px;
}
.wrap {
width: min(1100px, 100%);
margin: 0 auto;
background: var(--card);
border-radius: 16px;
box-shadow: 0 20px 45px rgba(2, 6, 23, 0.38);
padding: 20px;
}
h1 {
margin: 0;
}
.top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.sub {
margin: 6px 0 0;
color: var(--muted);
}
.home {
text-decoration: none;
font-weight: 700;
background: #e0f2fe;
color: #075985;
padding: 8px 12px;
border-radius: 8px;
}
.filters {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 10px;
margin: 16px 0;
}
.filters input,
.filters button {
width: 100%;
padding: 10px;
border-radius: 8px;
border: 1px solid #bfdbfe;
font: inherit;
}
.filters button {
background: var(--accent);
color: #082f49;
font-weight: 700;
cursor: pointer;
border: none;
}
.error {
margin-top: 8px;
background: var(--error-bg);
color: var(--error-text);
border: 1px solid var(--error-line);
padding: 10px 12px;
border-radius: 8px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
font-size: 0.95rem;
}
th, td {
border-bottom: 1px solid var(--line);
text-align: left;
padding: 10px 8px;
vertical-align: top;
}
th {
font-size: 0.85rem;
letter-spacing: 0.03em;
text-transform: uppercase;
color: #1e3a8a;
background: #eff6ff;
}
.muted {
color: var(--muted);
margin-top: 8px;
}
@media (max-width: 900px) {
.filters {
grid-template-columns: 1fr 1fr;
}
table {
display: block;
overflow-x: auto;
}
}
</style>
</head>
<body>
<main class="wrap">
<div class="top">
<div>
<h1>Upcoming Missions</h1>
<p class="sub">Live data from Launch Library 2</p>
</div>
<a href="/" class="home">Home</a>
</div>
<div style="margin-bottom: 12px;">
<a href="/sky" class="home" style="margin-right: 8px;">Sky Position</a>
</div>
<form class="filters" method="get" action="/missions">
<input type="text" name="agency" th:value="${agency}" placeholder="Agency (e.g. SpaceX)">
<input type="text" name="status" th:value="${status}" placeholder="Status (e.g. Go)">
<input type="number" name="year" th:value="${year}" placeholder="Year (e.g. 2026)">
<button type="submit">Apply Filters</button>
</form>
<div class="error" th:if="${error}" th:text="${error}">Could not load mission data right now.</div>
<table th:if="${missions != null and !#lists.isEmpty(missions)}">
<thead>
<tr>
<th>Mission</th>
<th>Agency</th>
<th>Status</th>
<th>Launch Date</th>
<th>Location</th>
<th>Type</th>
</tr>
</thead>
<tbody>
<tr th:each="mission : ${missions}">
<td th:text="${mission.name}">name</td>
<td th:text="${mission.agency}">agency</td>
<td th:text="${mission.status}">status</td>
<td th:text="${mission.launchDate}">launchDate</td>
<td th:text="${mission.location}">location</td>
<td th:text="${mission.missionType}">type</td>
</tr>
</tbody>
</table>
<p class="muted" th:if="${missions != null and #lists.isEmpty(missions)}">
No missions matched your filters.
</p>
</main>
</body>
</html>

View file

@ -0,0 +1,225 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sky Position</title>
<style>
:root {
--bg1: #07121f;
--bg2: #113450;
--card: #ffffff;
--text: #0f172a;
--muted: #475569;
--line: #dbeafe;
--accent: #38bdf8;
--error-bg: #fef2f2;
--error-text: #991b1b;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
background: linear-gradient(145deg, var(--bg1), var(--bg2));
min-height: 100vh;
color: var(--text);
padding: 24px;
}
.wrap {
width: min(1100px, 100%);
margin: 0 auto;
background: var(--card);
border-radius: 16px;
box-shadow: 0 18px 40px rgba(2, 6, 23, 0.35);
padding: 20px;
}
.top {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 14px;
}
.nav {
display: flex;
gap: 8px;
}
.nav a {
text-decoration: none;
font-weight: 700;
background: #e0f2fe;
color: #075985;
padding: 8px 12px;
border-radius: 8px;
}
form {
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(120px, 1fr));
margin-bottom: 10px;
}
input, button {
width: 100%;
padding: 10px;
border: 1px solid #bfdbfe;
border-radius: 8px;
font: inherit;
}
button {
border: none;
background: var(--accent);
color: #082f49;
font-weight: 700;
cursor: pointer;
}
.wide {
grid-column: span 3;
}
.error {
background: var(--error-bg);
color: var(--error-text);
padding: 10px 12px;
border-radius: 8px;
margin: 10px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
}
th, td {
padding: 10px 8px;
border-bottom: 1px solid var(--line);
text-align: left;
}
th {
background: #eff6ff;
color: #1e3a8a;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.yes {
color: #166534;
font-weight: 700;
}
.no {
color: #991b1b;
font-weight: 700;
}
.hint {
color: var(--muted);
margin-top: 8px;
}
@media (max-width: 900px) {
form {
grid-template-columns: 1fr 1fr;
}
.wide {
grid-column: span 2;
}
table {
display: block;
overflow-x: auto;
}
}
</style>
</head>
<body>
<main class="wrap">
<div class="top">
<div>
<h1>Sky Position</h1>
<p class="hint">See where major sky objects are relative to the user.</p>
</div>
<div class="nav">
<a href="/">Home</a>
<a href="/missions">Missions</a>
</div>
</div>
<form method="get" action="/sky" id="skyForm">
<input type="number" step="any" name="latitude" id="latitude" th:value="${latitude}" placeholder="Latitude">
<input type="number" step="any" name="longitude" id="longitude" th:value="${longitude}" placeholder="Longitude">
<button type="button" id="geoButton">Use My Location</button>
<input type="text" class="wide" name="address" th:value="${address}" placeholder="Or enter address/city (e.g. New York, NY)">
<input type="date" name="date" th:value="${date}">
<input type="time" name="time" th:value="${time}">
<button type="submit">Get Sky Positions</button>
</form>
<div class="error" th:if="${error}" th:text="${error}">Could not load sky positions right now.</div>
<table th:if="${objects != null and !#lists.isEmpty(objects)}">
<thead>
<tr>
<th>Object</th>
<th>Altitude (deg)</th>
<th>Azimuth (deg)</th>
<th>Direction</th>
<th>Visible</th>
</tr>
</thead>
<tbody>
<tr th:each="obj : ${objects}">
<td th:text="${obj.name}">name</td>
<td th:text="${obj.altitudeDegrees}">0.0</td>
<td th:text="${obj.azimuthDegrees}">0.0</td>
<td th:text="${obj.direction}">N</td>
<td th:classappend="${obj.visible} ? 'yes' : 'no'" th:text="${obj.visible} ? 'Yes' : 'No'">Yes</td>
</tr>
</tbody>
</table>
<p class="hint" th:if="${objects != null and #lists.isEmpty(objects)}">No sky objects returned for current input.</p>
</main>
<script>
const geoButton = document.getElementById("geoButton");
const latitudeInput = document.getElementById("latitude");
const longitudeInput = document.getElementById("longitude");
geoButton.addEventListener("click", () => {
if (!navigator.geolocation) {
alert("Geolocation is not supported in this browser.");
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
latitudeInput.value = position.coords.latitude.toFixed(6);
longitudeInput.value = position.coords.longitude.toFixed(6);
},
() => {
alert("Could not get your location. You can still type an address.");
}
);
});
</script>
</body>
</html>