mirror of
https://github.com/System-End/APCSA-Final.git
synced 2026-04-19 18:35:21 +00:00
feat: add missions and sky-position pages
This commit is contained in:
parent
b3e5bc6619
commit
c7cb55c9a8
11 changed files with 937 additions and 0 deletions
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
57
src/main/java/com/apcsa/spacetracker/model/Mission.java
Normal file
57
src/main/java/com/apcsa/spacetracker/model/Mission.java
Normal 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;
|
||||
}
|
||||
}
|
||||
37
src/main/java/com/apcsa/spacetracker/model/SkyObject.java
Normal file
37
src/main/java/com/apcsa/spacetracker/model/SkyObject.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
108
src/main/java/com/apcsa/spacetracker/service/MissionService.java
Normal file
108
src/main/java/com/apcsa/spacetracker/service/MissionService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
125
src/main/java/com/apcsa/spacetracker/service/SkyService.java
Normal file
125
src/main/java/com/apcsa/spacetracker/service/SkyService.java
Normal 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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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:}
|
||||
|
|
|
|||
|
|
@ -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}">
|
||||
|
|
|
|||
193
src/main/resources/templates/missions.html
Normal file
193
src/main/resources/templates/missions.html
Normal 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>
|
||||
225
src/main/resources/templates/sky.html
Normal file
225
src/main/resources/templates/sky.html
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue