feat(frontend): Add restricted area visualisation

This commit is contained in:
js0ny 2025-12-06 05:55:59 +00:00
parent 47993d66bc
commit 83bb72faac
13 changed files with 1589 additions and 1465 deletions

View file

@ -48,12 +48,16 @@
let map; let map;
let pathLayer; let pathLayer;
let markerLayer; let markerLayer;
let restrictedLayer;
let servicePointLayer;
let currentBounds = fallbackBounds; let currentBounds = fallbackBounds;
let apiBase = "http://localhost:8080/api/v1"; let apiBase = "http://localhost:8080/api/v1";
let blackBoxBase = "http://localhost:3000"; let blackBoxBase = "http://localhost:3000";
let dispatchBody = JSON.stringify(defaultDispatch, null, 2); let dispatchBody = JSON.stringify(defaultDispatch, null, 2);
let plannedPath = samplePathResponse; let plannedPath = samplePathResponse;
let restrictedAreas = [];
let servicePoints = [];
let stepSeconds = STEP_SECONDS; let stepSeconds = STEP_SECONDS;
let status = "System Ready. Waiting for dispatch payload."; let status = "System Ready. Waiting for dispatch payload.";
@ -101,7 +105,11 @@
// Reactive Statements // Reactive Statements
$: timeline = buildTimeline(plannedPath); $: timeline = buildTimeline(plannedPath);
$: totalSteps = Math.max(timeline.totalSteps, plannedPath?.totalMoves || 1, 1); $: totalSteps = Math.max(
timeline.totalSteps,
plannedPath?.totalMoves || 1,
1,
);
$: tick = Math.min(tick, totalSteps - 1); $: tick = Math.min(tick, totalSteps - 1);
$: playbackSeconds = tick * stepSeconds; $: playbackSeconds = tick * stepSeconds;
$: wallClock = computeWallClock(startTime, playbackSeconds); $: wallClock = computeWallClock(startTime, playbackSeconds);
@ -112,7 +120,12 @@
? computeBounds( ? computeBounds(
positionsNow positionsNow
.map((d) => d.current) .map((d) => d.current)
.filter((p) => p && typeof p.lat === "number" && typeof p.lng === "number"), .filter(
(p) =>
p &&
typeof p.lat === "number" &&
typeof p.lng === "number",
),
) )
: timeline.bounds || fallbackBounds; : timeline.bounds || fallbackBounds;
@ -178,9 +191,13 @@
pathLayer = L.layerGroup().addTo(map); pathLayer = L.layerGroup().addTo(map);
markerLayer = L.layerGroup().addTo(map); markerLayer = L.layerGroup().addTo(map);
restrictedLayer = L.layerGroup().addTo(map);
servicePointLayer = L.layerGroup().addTo(map);
fitMapToBounds(); fitMapToBounds();
refreshMap(); refreshMap();
loadRestrictedAreas();
loadServicePoints();
return () => map?.remove(); return () => map?.remove();
}); });
@ -223,7 +240,40 @@
await fetchSnapshotForTime(wallClock, false); await fetchSnapshotForTime(wallClock, false);
} }
async function fetchSnapshotForTime(requestedTime = wallClock, silent = false) { async function loadRestrictedAreas() {
try {
const res = await fetch(`${apiBase}/restrictedAreas`);
if (!res.ok)
throw new Error(
res.statusText || "failed to load restricted areas",
);
const data = await res.json();
restrictedAreas = Array.isArray(data) ? data : [];
drawRestrictedAreas();
} catch (err) {
console.error("[RestrictedAreas] fetch failed:", err);
}
}
async function loadServicePoints() {
try {
const res = await fetch(`${apiBase}/servicePoints`);
if (!res.ok)
throw new Error(
res.statusText || "failed to load service points",
);
const data = await res.json();
servicePoints = Array.isArray(data) ? data : [];
drawServicePoints();
} catch (err) {
console.error("[ServicePoints] fetch failed:", err);
}
}
async function fetchSnapshotForTime(
requestedTime = wallClock,
silent = false,
) {
if (!requestedTime) return; if (!requestedTime) return;
if (silent && requestedTime === lastSnapshotTime) return; if (silent && requestedTime === lastSnapshotTime) return;
@ -366,6 +416,50 @@
}); });
} }
function drawRestrictedAreas() {
if (!restrictedLayer) return;
restrictedLayer.clearLayers();
restrictedAreas.forEach((area) => {
const coords = (area?.vertices || [])
.map((v) => [v.lat, v.lng])
.filter((p) => Array.isArray(p) && p.length === 2);
if (coords.length < 3) return;
L.polygon(coords, {
color: "#ef4444", // red
weight: 2,
fill: false,
dashArray: "6,6",
})
.bindTooltip(area?.name || "Restricted Area", { sticky: true })
.addTo(restrictedLayer);
});
}
function drawServicePoints() {
if (!servicePointLayer) return;
servicePointLayer.clearLayers();
servicePoints.forEach((sp) => {
const loc = sp?.location;
if (
!loc ||
typeof loc.lat !== "number" ||
typeof loc.lng !== "number"
)
return;
const marker = L.marker([loc.lat, loc.lng], {
icon: L.divIcon({
className: "service-point-icon",
html: `<div class="triangle-marker"></div>`,
iconSize: [16, 16],
iconAnchor: [8, 8],
}),
}).bindTooltip(sp?.name || `Service Point ${sp?.id ?? ""}`, {
sticky: true,
});
marker.addTo(servicePointLayer);
});
}
function toggleTheme() { function toggleTheme() {
theme.update((t) => (t === "dark" ? "light" : "dark")); theme.update((t) => (t === "dark" ? "light" : "dark"));
} }
@ -410,9 +504,7 @@
const coords = timelinePaths.get(String(id)) || []; const coords = timelinePaths.get(String(id)) || [];
const matchedIdx = findClosestIndex(coords, point); const matchedIdx = findClosestIndex(coords, point);
const visited = const visited =
matchedIdx >= 0 matchedIdx >= 0 ? coords.slice(0, matchedIdx + 1) : [point];
? coords.slice(0, matchedIdx + 1)
: [point];
const fullPath = coords.length ? coords : [point]; const fullPath = coords.length ? coords : [point];
return { return {
id, id,
@ -477,7 +569,6 @@
<PlaybackControls <PlaybackControls
{wallClock} {wallClock}
{playbackSeconds} {playbackSeconds}
totalCost={plannedPath?.totalCost}
activeDrones={plannedPath?.dronePaths?.length} activeDrones={plannedPath?.dronePaths?.length}
{isPlaying} {isPlaying}
bind:tick bind:tick

View file

@ -24,40 +24,49 @@
} }
.leaflet-container { .leaflet-container {
background: #111 !important; background: #111 !important;
} }
.drone-tooltip { .drone-tooltip {
@apply bg-gray-800 text-white p-2 rounded-md border-gray-700 border; @apply bg-gray-800 text-white p-2 rounded-md border-gray-700 border;
} }
/* Arrow should be the same color as the tooltip background */ /* Arrow should be the same color as the tooltip background */
.drone-tooltip.leaflet-tooltip-top::before { .drone-tooltip.leaflet-tooltip-top::before {
border-top-color: theme('colors.gray.800'); border-top-color: theme("colors.gray.800");
} }
/* Custom Scrollbar */ /* Custom Scrollbar */
.custom-scrollbar::-webkit-scrollbar { .custom-scrollbar::-webkit-scrollbar {
width: 6px; width: 6px;
} }
.custom-scrollbar::-webkit-scrollbar-track { .custom-scrollbar::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
.custom-scrollbar::-webkit-scrollbar-thumb { .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(148, 163, 184, 0.3); /* Slate 400 with opacity */ background-color: rgba(148, 163, 184, 0.3); /* Slate 400 with opacity */
border-radius: 20px; border-radius: 20px;
} }
.custom-scrollbar:hover::-webkit-scrollbar-thumb { .custom-scrollbar:hover::-webkit-scrollbar-thumb {
background-color: rgba(148, 163, 184, 0.5); background-color: rgba(148, 163, 184, 0.5);
} }
/* Drone Popup Styles */ /* Drone Popup Styles */
.drone-popup .leaflet-popup-content-wrapper { .drone-popup .leaflet-popup-content-wrapper {
@apply bg-slate-900/90 backdrop-blur-md border border-slate-700 text-slate-100 rounded-lg p-0 shadow-xl; @apply bg-slate-900/90 backdrop-blur-md border border-slate-700 text-slate-100 rounded-lg p-0 shadow-xl;
} }
.drone-popup .leaflet-popup-tip { .drone-popup .leaflet-popup-tip {
@apply bg-slate-900/90 border-slate-700; @apply bg-slate-900/90 border-slate-700;
} }
.drone-popup .leaflet-popup-content { .drone-popup .leaflet-popup-content {
margin: 12px; margin: 12px;
}
.service-point-icon .triangle-marker {
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 10px solid #0ea5e9; /* sky-500 */
transform: translateY(-2px);
} }

View file

@ -4,7 +4,6 @@
export let wallClock = ""; export let wallClock = "";
export let playbackSeconds = 0; export let playbackSeconds = 0;
export let totalCost = 0;
export let activeDrones = 0; export let activeDrones = 0;
export let isPlaying = false; export let isPlaying = false;
export let tick = 0; export let tick = 0;
@ -48,22 +47,13 @@
>{formatDuration(playbackSeconds)}</span >{formatDuration(playbackSeconds)}</span
> >
</div> </div>
<div class="flex flex-col">
<span class="text-xs uppercase text-slate-500 dark:text-slate-400"
>Total Cost</span
>
<span
class="font-mono text-xl font-bold text-slate-800 dark:text-slate-100"
>{totalCost ?? "—"}</span
>
</div>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-xs uppercase text-slate-500 dark:text-slate-400" <span class="text-xs uppercase text-slate-500 dark:text-slate-400"
>Active Drones</span >Active Drones</span
> >
<span <span
class="font-mono text-xl font-bold text-slate-800 dark:text-slate-100" class="font-mono text-xl font-bold text-slate-800 dark:text-slate-100"
>{activeDrones ?? 0}</span >{snapshot.length ?? 0}</span
> >
</div> </div>
</div> </div>

View file

@ -23,7 +23,9 @@
</script> </script>
<aside <aside
class="absolute top-0 left-0 bottom-0 w-[380px] pointer-events-auto bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl border border-slate-200 dark:border-slate-800 shadow-2xl flex flex-col m-4 rounded-2xl p-5 gap-5 transition-transform duration-500 ease-in-out {open ? 'translate-x-0' : '-translate-x-[120%]'}" class="absolute top-0 left-0 bottom-0 w-[380px] pointer-events-auto bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl border border-slate-200 dark:border-slate-800 shadow-2xl flex flex-col m-4 rounded-2xl p-5 gap-5 transition-transform duration-500 ease-in-out {open
? 'translate-x-0'
: '-translate-x-[120%]'}"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h1 <h1
@ -90,11 +92,27 @@
{/if} {/if}
</button> </button>
<button <button
on:click={() => dispatch('close')} on:click={() => dispatch("close")}
class="p-2 rounded-lg text-slate-500 hover:text-sky-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" class="p-2 rounded-lg text-slate-500 hover:text-sky-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label="Close Sidebar" aria-label="Close Sidebar"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg> <svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="18" y1="6" x2="6" y2="18"></line><line
x1="6"
y1="6"
x2="18"
y2="18"
></line></svg
>
</button> </button>
</div> </div>
</div> </div>
@ -105,7 +123,9 @@
> {status} > {status}
</div> </div>
<div class="space-y-3 overflow-y-auto pr-1 custom-scrollbar flex-1 flex flex-col"> <div
class="space-y-3 overflow-y-auto pr-1 custom-scrollbar flex-1 flex flex-col"
>
<div class="border-b border-slate-200 dark:border-slate-800 pb-2"> <div class="border-b border-slate-200 dark:border-slate-800 pb-2">
<button <button
class="w-full flex items-center justify-between text-sm font-semibold tracking-wider uppercase text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors focus:outline-none" class="w-full flex items-center justify-between text-sm font-semibold tracking-wider uppercase text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors focus:outline-none"
@ -189,7 +209,6 @@
class="w-full bg-white/50 dark:bg-black/30 border border-slate-300 dark:border-slate-700 text-slate-800 dark:text-slate-100 p-2 rounded-lg font-mono text-xs transition-all focus:outline-none focus:border-sky-500 focus:bg-white dark:focus:bg-black/50 focus:ring-2 focus:ring-sky-500/20" class="w-full bg-white/50 dark:bg-black/30 border border-slate-300 dark:border-slate-700 text-slate-800 dark:text-slate-100 p-2 rounded-lg font-mono text-xs transition-all focus:outline-none focus:border-sky-500 focus:bg-white dark:focus:bg-black/50 focus:ring-2 focus:ring-sky-500/20"
/> />
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
@ -207,15 +226,16 @@
></textarea> ></textarea>
</div> </div>
<div class="flex gap-2 mt-auto pt-4 border-t border-slate-200 dark:border-slate-800"> <div
class="flex gap-2 mt-auto pt-4 border-t border-slate-200 dark:border-slate-800"
>
<button <button
class="flex-1 p-2.5 rounded-lg font-semibold text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed text-white bg-sky-500 shadow-[0_4px_12px_theme(colors.sky.500/30)] hover:bg-sky-600 disabled:hover:bg-sky-500" class="flex-1 p-2.5 rounded-lg font-semibold text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed text-white bg-sky-500 shadow-[0_4px_12px_theme(colors.sky.500/30)] hover:bg-sky-600 disabled:hover:bg-sky-500"
on:click={() => dispatch('request')} on:click={() => dispatch("request")}
disabled={loading} disabled={loading}
> >
{loading ? "CALCULATING..." : "REQUEST PATH"} {loading ? "CALCULATING..." : "REQUEST PATH"}
</button> </button>
</div> </div>
</div> </div>
</aside> </aside>

View file

@ -1,8 +1,8 @@
import './app.css'; import "./app.css";
import App from './App.svelte'; import App from "./App.svelte";
const app = new App({ const app = new App({
target: document.getElementById('app') target: document.getElementById("app"),
}); });
export default app; export default app;

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,3 @@
import { writable } from 'svelte/store'; import { writable } from "svelte/store";
export const theme = writable('dark'); export const theme = writable("dark");

View file

@ -1,108 +1,103 @@
import { fallbackBounds } from "./sampleData.js"; import { fallbackBounds } from "./sampleData.js";
export function buildTimeline(response) { export function buildTimeline(response) {
const drones = []; const drones = [];
const allPoints = []; const allPoints = [];
let longest = 0; let longest = 0;
if (!response || !response.dronePaths) { if (!response || !response.dronePaths) {
return { drones, totalSteps: 0, bounds: fallbackBounds }; return { drones, totalSteps: 0, bounds: fallbackBounds };
} }
for (const path of response.dronePaths) { for (const path of response.dronePaths) {
const coords = flattenDeliveries(path.deliveries); const coords = flattenDeliveries(path.deliveries);
if (!coords.length) continue; if (!coords.length) continue;
drones.push({ id: path.droneId, path: coords }); drones.push({ id: path.droneId, path: coords });
coords.forEach((p) => allPoints.push(p)); coords.forEach((p) => allPoints.push(p));
longest = Math.max(longest, coords.length); longest = Math.max(longest, coords.length);
} }
// Add padding points for bounds calculation if empty // Add padding points for bounds calculation if empty
const basePoints = const basePoints =
allPoints.length > 0 allPoints.length > 0
? allPoints ? allPoints
: [ : [
{ {
lng: fallbackBounds.minLng, lng: fallbackBounds.minLng,
lat: fallbackBounds.minLat, lat: fallbackBounds.minLat,
}, },
{ {
lng: fallbackBounds.maxLng, lng: fallbackBounds.maxLng,
lat: fallbackBounds.maxLat, lat: fallbackBounds.maxLat,
}, },
]; ];
return { return {
drones, drones,
totalSteps: longest || 0, totalSteps: longest || 0,
bounds: computeBounds(basePoints), bounds: computeBounds(basePoints),
}; };
} }
function flattenDeliveries(deliveries) { function flattenDeliveries(deliveries) {
const coords = []; const coords = [];
for (const delivery of deliveries || []) { for (const delivery of deliveries || []) {
for (const point of delivery.flightPath || []) { for (const point of delivery.flightPath || []) {
if ( if (!coords.length || !samePoint(coords[coords.length - 1], point)) {
!coords.length || coords.push(point);
!samePoint(coords[coords.length - 1], point) }
) {
coords.push(point);
}
}
} }
return coords; }
return coords;
} }
function samePoint(a, b) { function samePoint(a, b) {
return Math.abs(a.lng - b.lng) < 1e-9 && Math.abs(a.lat - b.lat) < 1e-9; return Math.abs(a.lng - b.lng) < 1e-9 && Math.abs(a.lat - b.lat) < 1e-9;
} }
export function computeBounds(points) { export function computeBounds(points) {
if (!points.length) return fallbackBounds; if (!points.length) return fallbackBounds;
let minLng = Infinity, let minLng = Infinity,
maxLng = -Infinity, maxLng = -Infinity,
minLat = Infinity, minLat = Infinity,
maxLat = -Infinity; maxLat = -Infinity;
for (const p of points) { for (const p of points) {
minLng = Math.min(minLng, p.lng); minLng = Math.min(minLng, p.lng);
maxLng = Math.max(maxLng, p.lng); maxLng = Math.max(maxLng, p.lng);
minLat = Math.min(minLat, p.lat); minLat = Math.min(minLat, p.lat);
maxLat = Math.max(maxLat, p.lat); maxLat = Math.max(maxLat, p.lat);
} }
const padding = 0.002; // Slightly more padding const padding = 0.002; // Slightly more padding
return { return {
minLng: minLng - padding, minLng: minLng - padding,
maxLng: maxLng + padding, maxLng: maxLng + padding,
minLat: minLat - padding, minLat: minLat - padding,
maxLat: maxLat + padding, maxLat: maxLat + padding,
}; };
} }
export function colorFor(idx, palette) { export function colorFor(idx, palette) {
if (!palette || palette.length === 0) return "#000"; if (!palette || palette.length === 0) return "#000";
return palette[idx % palette.length]; return palette[idx % palette.length];
} }
export function formatDuration(sec) { export function formatDuration(sec) {
const m = Math.floor(sec / 60) const m = Math.floor(sec / 60)
.toString() .toString()
.padStart(2, "0"); .padStart(2, "0");
const s = Math.floor(sec % 60) const s = Math.floor(sec % 60)
.toString() .toString()
.padStart(2, "0"); .padStart(2, "0");
return `${m}:${s}`; return `${m}:${s}`;
} }
export function computeWallClock(start, seconds) { export function computeWallClock(start, seconds) {
const base = start ? new Date(start) : new Date(); const base = start ? new Date(start) : new Date();
const ts = new Date(base.getTime() + seconds * 1000); const ts = new Date(base.getTime() + seconds * 1000);
return isNaN(ts.getTime()) return isNaN(ts.getTime()) ? new Date().toISOString() : ts.toISOString();
? new Date().toISOString()
: ts.toISOString();
} }
export function boundsKey(bounds) { export function boundsKey(bounds) {
if (!bounds) return ""; if (!bounds) return "";
return `${bounds.minLat}:${bounds.maxLat}:${bounds.minLng}:${bounds.maxLng}`; return `${bounds.minLat}:${bounds.maxLat}:${bounds.minLng}:${bounds.maxLng}`;
} }

View file

@ -38,6 +38,7 @@
bun bun
svelte-language-server svelte-language-server
typescript-language-server typescript-language-server
prettier
]; ];
shellHook = '' shellHook = ''
export JAVA_HOME=${pkgs.jdk21} export JAVA_HOME=${pkgs.jdk21}

View file

@ -25,6 +25,20 @@ body:json {
"lng": -3.167074009381139, "lng": -3.167074009381139,
"lat": 55.94740195123114 "lat": 55.94740195123114
} }
},
{
"id": 6,
"date": "2025-11-22",
"time": "09:35",
"requirements": {
"capacity": 0.85,
"heating": true,
"maxCost": 13.5
},
"delivery": {
"lng": -3.1891515471685636,
"lat": 55.95347060952466
}
} }
] ]
} }

View file

@ -1,25 +0,0 @@
package io.github.js0ny.ilp_coursework.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import io.github.js0ny.ilp_coursework.service.DroneInfoService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1")
public class GeoJsonDataController {
private final DroneInfoService droneInfoService;
public GeoJsonDataController(DroneInfoService droneInfoService) {
this.droneInfoService = droneInfoService;
}
@GetMapping("/getAllRestrictedAreaByGeoJson")
public String getAllRestrictedAreaGeoJson() throws JsonProcessingException {
return droneInfoService.fetchRestrictedAreasInGeoJson().stream().reduce("", String::concat);
}
}

View file

@ -0,0 +1,32 @@
package io.github.js0ny.ilp_coursework.controller;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.github.js0ny.ilp_coursework.data.external.RestrictedArea;
import io.github.js0ny.ilp_coursework.data.external.ServicePoint;
import io.github.js0ny.ilp_coursework.service.DroneInfoService;
@RestController
@RequestMapping("/api/v1")
public class MapMetaController {
private final DroneInfoService droneInfoService;
public MapMetaController(DroneInfoService droneInfoService) {
this.droneInfoService = droneInfoService;
}
@GetMapping("/restrictedAreas")
public List<RestrictedArea> getRestrictedAreas() {
return droneInfoService.fetchRestrictedAreas();
}
@GetMapping("/servicePoints")
public List<ServicePoint> getServicePoints() {
return droneInfoService.fetchServicePoints();
}
}

View file

@ -54,13 +54,15 @@ public class DroneInfoService {
/** /**
* Return an array of ids of drones with/without cooling capability * Return an array of ids of drones with/without cooling capability
* *
* <p>Associated service method with {@code /dronesWithCooling/{state}} * <p>
* Associated service method with {@code /dronesWithCooling/{state}}
* *
* @param state determines the capability filtering * @param state determines the capability filtering
* @return if {@code state} is true, return ids of drones with cooling capability, else without * @return if {@code state} is true, return ids of drones with cooling
* cooling * capability, else without
* cooling
* @see * @see
* io.github.js0ny.ilp_coursework.controller.DroneController#getDronesWithCoolingCapability(boolean) * io.github.js0ny.ilp_coursework.controller.DroneController#getDronesWithCoolingCapability(boolean)
*/ */
public List<String> dronesWithCooling(boolean state) { public List<String> dronesWithCooling(boolean state) {
// URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint); // URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint);
@ -80,13 +82,16 @@ public class DroneInfoService {
/** /**
* Return a {@link Drone}-style json data structure with the given {@code id} * Return a {@link Drone}-style json data structure with the given {@code id}
* *
* <p>Associated service method with {@code /droneDetails/{id}} * <p>
* Associated service method with {@code /droneDetails/{id}}
* *
* @param id The id of the drone * @param id The id of the drone
* @return drone json body of given id * @return drone json body of given id
* @throws NullPointerException when cannot fetch available drones from remote * @throws NullPointerException when cannot fetch available drones from
* @throws IllegalArgumentException when drone with given {@code id} cannot be found this should * remote
* lead to a 404 * @throws IllegalArgumentException when drone with given {@code id} cannot be
* found this should
* lead to a 404
* @see io.github.js0ny.ilp_coursework.controller.DroneController#getDroneDetail(String) * @see io.github.js0ny.ilp_coursework.controller.DroneController#getDroneDetail(String)
*/ */
public Drone droneDetail(String id) { public Drone droneDetail(String id) {
@ -103,10 +108,12 @@ public class DroneInfoService {
} }
/** /**
* Return an array of ids of drones that match all the requirements in the medical dispatch * Return an array of ids of drones that match all the requirements in the
* medical dispatch
* records * records
* *
* <p>Associated service method with * <p>
* Associated service method with
* *
* @param rec array of medical dispatch records * @param rec array of medical dispatch records
* @return List of drone ids that match all the requirements * @return List of drone ids that match all the requirements
@ -128,10 +135,9 @@ public class DroneInfoService {
return drones.stream() return drones.stream()
.filter(d -> d != null && d.capability() != null) .filter(d -> d != null && d.capability() != null)
.filter( .filter(
d -> d -> Arrays.stream(rec)
Arrays.stream(rec) .filter(r -> r != null && r.requirements() != null)
.filter(r -> r != null && r.requirements() != null) .allMatch(r -> droneMatchesRequirement(d, r)))
.allMatch(r -> droneMatchesRequirement(d, r)))
.map(Drone::id) .map(Drone::id)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@ -139,11 +145,13 @@ public class DroneInfoService {
/** /**
* Helper to check if a drone meets the requirement of a medical dispatch. * Helper to check if a drone meets the requirement of a medical dispatch.
* *
* @param drone the drone to be checked * @param drone the drone to be checked
* @param record the medical dispatch record containing the requirement * @param record the medical dispatch record containing the requirement
* @return true if the drone meets the requirement, false otherwise * @return true if the drone meets the requirement, false otherwise
* @throws IllegalArgumentException when record requirements or drone capability is invalid * @throws IllegalArgumentException when record requirements or drone capability
* (capacity and id cannot be null in {@code MedDispathRecDto}) * is invalid
* (capacity and id cannot be null in
* {@code MedDispathRecDto})
*/ */
public boolean droneMatchesRequirement(Drone drone, MedDispatchRecRequest record) { public boolean droneMatchesRequirement(Drone drone, MedDispatchRecRequest record) {
var requirements = record.requirements(); var requirements = record.requirements();
@ -183,13 +191,13 @@ public class DroneInfoService {
* Helper to check if a drone is available at the required date and time * Helper to check if a drone is available at the required date and time
* *
* @param droneId the id of the drone to be checked * @param droneId the id of the drone to be checked
* @param record the medical dispatch record containing the required date and time * @param record the medical dispatch record containing the required date and
* time
* @return true if the drone is available, false otherwise * @return true if the drone is available, false otherwise
*/ */
private boolean checkAvailability(String droneId, MedDispatchRecRequest record) { private boolean checkAvailability(String droneId, MedDispatchRecRequest record) {
URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint); URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePoints = ServicePointDrones[] servicePoints = restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
LocalDate requiredDate = record.date(); LocalDate requiredDate = record.date();
DayOfWeek requiredDay = requiredDate.getDayOfWeek(); DayOfWeek requiredDay = requiredDate.getDayOfWeek();
@ -208,8 +216,7 @@ public class DroneInfoService {
private LngLat queryServicePointLocationByDroneId(String droneId) { private LngLat queryServicePointLocationByDroneId(String droneId) {
URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint); URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePoints = ServicePointDrones[] servicePoints = restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
restTemplate.getForObject(droneUrl, ServicePointDrones[].class);
assert servicePoints != null; assert servicePoints != null;
for (var sp : servicePoints) { for (var sp : servicePoints) {
@ -226,8 +233,7 @@ public class DroneInfoService {
private LngLat queryServicePointLocation(int id) { private LngLat queryServicePointLocation(int id) {
URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint); URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint);
ServicePoint[] servicePoints = ServicePoint[] servicePoints = restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
assert servicePoints != null; assert servicePoints != null;
for (var sp : servicePoints) { for (var sp : servicePoints) {
@ -250,31 +256,22 @@ public class DroneInfoService {
public List<RestrictedArea> fetchRestrictedAreas() { public List<RestrictedArea> fetchRestrictedAreas() {
URI restrictedUrl = URI.create(baseUrl).resolve(restrictedAreasEndpoint); URI restrictedUrl = URI.create(baseUrl).resolve(restrictedAreasEndpoint);
RestrictedArea[] restrictedAreas = RestrictedArea[] restrictedAreas = restTemplate.getForObject(restrictedUrl, RestrictedArea[].class);
restTemplate.getForObject(restrictedUrl, RestrictedArea[].class);
assert restrictedAreas != null; assert restrictedAreas != null;
return Arrays.asList(restrictedAreas); return Arrays.asList(restrictedAreas);
} }
public List<String> fetchRestrictedAreasInGeoJson() throws JsonProcessingException {
var mapper = new ObjectMapper();
var ras = fetchRestrictedAreas();
var geoJson = ras.stream().map(RestrictedArea::toRegion).map(Region::toGeoJson).toList();
return Collections.singletonList(mapper.writeValueAsString(geoJson));
}
public List<ServicePoint> fetchServicePoints() { public List<ServicePoint> fetchServicePoints() {
URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint); URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint);
ServicePoint[] servicePoints = ServicePoint[] servicePoints = restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
restTemplate.getForObject(servicePointUrl, ServicePoint[].class);
assert servicePoints != null; assert servicePoints != null;
return Arrays.asList(servicePoints); return Arrays.asList(servicePoints);
} }
public List<ServicePointDrones> fetchDronesForServicePoints() { public List<ServicePointDrones> fetchDronesForServicePoints() {
URI servicePointDronesUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint); URI servicePointDronesUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint);
ServicePointDrones[] servicePointDrones = ServicePointDrones[] servicePointDrones = restTemplate.getForObject(servicePointDronesUrl,
restTemplate.getForObject(servicePointDronesUrl, ServicePointDrones[].class); ServicePointDrones[].class);
assert servicePointDrones != null; assert servicePointDrones != null;
return Arrays.asList(servicePointDrones); return Arrays.asList(servicePointDrones);
} }