Frontend Basics
This commit is contained in:
parent
fde16fa283
commit
3bbfed11fa
28 changed files with 2780 additions and 7 deletions
400
drone-frontend/src/App.svelte
Normal file
400
drone-frontend/src/App.svelte
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
<script>
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import L from "leaflet";
|
||||
import { theme } from "./stores.js";
|
||||
import {
|
||||
STEP_SECONDS,
|
||||
defaultDispatch,
|
||||
samplePathResponse,
|
||||
fallbackBounds,
|
||||
} from "./sampleData.js";
|
||||
import Sidebar from "./components/Sidebar.svelte";
|
||||
import PlaybackControls from "./components/PlaybackControls.svelte";
|
||||
import {
|
||||
buildTimeline,
|
||||
computeWallClock,
|
||||
boundsKey,
|
||||
colorFor,
|
||||
} from "./utils.js";
|
||||
|
||||
let palette = [];
|
||||
let lightTiles, darkTiles;
|
||||
|
||||
function updatePalette() {
|
||||
if (typeof document === "undefined") return;
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const newPalette = Array.from({ length: 6 }, (_, i) =>
|
||||
// Reads CSS custom property --p-1 through --p-6 (palette colors defined in CSS)
|
||||
style.getPropertyValue(`--p-${i + 1}`).trim(),
|
||||
);
|
||||
|
||||
// Only update if colors are valid and different to avoid unnecessary redraws
|
||||
if (
|
||||
newPalette.some((c) => c) &&
|
||||
JSON.stringify(newPalette) !== JSON.stringify(palette)
|
||||
) {
|
||||
palette = newPalette;
|
||||
}
|
||||
}
|
||||
|
||||
// const fallbackBounds = {
|
||||
// minLng: -3.19,
|
||||
// maxLng: -3.17,
|
||||
// minLat: 55.94,
|
||||
// maxLat: 55.99,
|
||||
// };
|
||||
|
||||
let mapContainer;
|
||||
let map;
|
||||
let pathLayer;
|
||||
let markerLayer;
|
||||
let lastBoundsKey = "";
|
||||
|
||||
let apiBase = "http://localhost:8080/api/v1";
|
||||
let blackBoxBase = "http://localhost:3000";
|
||||
let dispatchBody = JSON.stringify(defaultDispatch, null, 2);
|
||||
let plannedPath = samplePathResponse;
|
||||
|
||||
let status = "System Ready. Waiting for dispatch payload.";
|
||||
let snapshotStatus = "";
|
||||
let snapshot = [];
|
||||
let tick = 0;
|
||||
let isPlaying = false;
|
||||
let loading = false;
|
||||
let playTimer;
|
||||
let startTime = new Date().toISOString().slice(0, 16);
|
||||
let sidebarOpen = true;
|
||||
|
||||
// Reactive Statements
|
||||
$: timeline = buildTimeline(plannedPath);
|
||||
$: totalSteps = Math.max(timeline.totalSteps, 1);
|
||||
$: tick = Math.min(tick, totalSteps - 1);
|
||||
$: playbackSeconds = tick * STEP_SECONDS;
|
||||
$: wallClock = computeWallClock(startTime, playbackSeconds);
|
||||
|
||||
// Compute positions based on current tick
|
||||
$: positionsNow = timeline.drones.map((drone) => ({
|
||||
...drone,
|
||||
current: drone.path[Math.min(tick, drone.path.length - 1)],
|
||||
visited: drone.path.slice(0, Math.min(tick + 1, drone.path.length)),
|
||||
}));
|
||||
|
||||
// Update Map
|
||||
$: if (map && palette.length > 0) {
|
||||
const nextKey = boundsKey(timeline.bounds);
|
||||
if (nextKey && nextKey !== lastBoundsKey) {
|
||||
fitMapToBounds();
|
||||
lastBoundsKey = nextKey;
|
||||
}
|
||||
refreshMap(positionsNow);
|
||||
}
|
||||
|
||||
// Update theme
|
||||
$: {
|
||||
if (typeof document !== "undefined") {
|
||||
if ($theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
if (map && lightTiles && darkTiles) {
|
||||
if (map.hasLayer(lightTiles)) map.removeLayer(lightTiles);
|
||||
if (!map.hasLayer(darkTiles)) map.addLayer(darkTiles);
|
||||
}
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
if (map && lightTiles && darkTiles) {
|
||||
if (map.hasLayer(darkTiles)) map.removeLayer(darkTiles);
|
||||
if (!map.hasLayer(lightTiles)) map.addLayer(lightTiles);
|
||||
}
|
||||
}
|
||||
// We need to wait for the browser to apply the new CSS variables
|
||||
setTimeout(updatePalette, 50);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updatePalette(); // Set initial palette
|
||||
|
||||
map = L.map(mapContainer, {
|
||||
preferCanvas: true,
|
||||
zoomControl: false, // We'll move zoom control or rely on scroll
|
||||
attributionControl: false, // Custom placement if needed, or keep minimal
|
||||
});
|
||||
|
||||
// ADD ZOOM CONTROL to Top Right
|
||||
L.control.zoom({ position: "topright" }).addTo(map);
|
||||
|
||||
const tileOptions = {
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
subdomains: "abcd",
|
||||
maxZoom: 20,
|
||||
};
|
||||
|
||||
lightTiles = L.tileLayer(
|
||||
"https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
|
||||
tileOptions,
|
||||
);
|
||||
darkTiles = L.tileLayer(
|
||||
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
|
||||
tileOptions,
|
||||
);
|
||||
|
||||
if ($theme === "dark") {
|
||||
darkTiles.addTo(map);
|
||||
} else {
|
||||
lightTiles.addTo(map);
|
||||
}
|
||||
|
||||
pathLayer = L.layerGroup().addTo(map);
|
||||
markerLayer = L.layerGroup().addTo(map);
|
||||
|
||||
fitMapToBounds();
|
||||
refreshMap();
|
||||
return () => map?.remove();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stopPlaying();
|
||||
});
|
||||
|
||||
async function requestPath() {
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(dispatchBody);
|
||||
} catch (err) {
|
||||
status = "Error: Invalid JSON format.";
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(payload)) {
|
||||
status = "Error: Payload must be an array.";
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
status = "Transmitting dispatch to API...";
|
||||
stopPlaying();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/calcDeliveryPath`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
|
||||
plannedPath = await res.json();
|
||||
tick = 0;
|
||||
status = "Flight path received. Ready for simulation.";
|
||||
} catch (err) {
|
||||
plannedPath = samplePathResponse;
|
||||
status = `API Link Failed. Using offline sample data. (${err.message})`;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadSample() {
|
||||
plannedPath = samplePathResponse;
|
||||
status = "Loaded sample simulation data.";
|
||||
tick = 0;
|
||||
stopPlaying();
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (isPlaying) {
|
||||
stopPlaying();
|
||||
} else if (totalSteps > 1) {
|
||||
isPlaying = true;
|
||||
clearInterval(playTimer);
|
||||
playTimer = setInterval(() => {
|
||||
tick = (tick + 1) % totalSteps;
|
||||
if (tick === totalSteps - 1) stopPlaying(); // Auto stop at end
|
||||
}, 650);
|
||||
}
|
||||
}
|
||||
|
||||
function stopPlaying() {
|
||||
isPlaying = false;
|
||||
if (playTimer) clearInterval(playTimer);
|
||||
}
|
||||
|
||||
async function fetchSnapshot() {
|
||||
snapshotStatus = "Querying Black Box...";
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${blackBoxBase}/snapshot?time=${encodeURIComponent(wallClock)}`,
|
||||
);
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
snapshot = await res.json();
|
||||
snapshotStatus = `Retrieved ${snapshot.length} events for ${wallClock}`;
|
||||
} catch (err) {
|
||||
snapshotStatus = `Connection Error: ${err.message}`;
|
||||
snapshot = [];
|
||||
}
|
||||
}
|
||||
|
||||
function fitMapToBounds() {
|
||||
if (!map || !timeline.bounds) return;
|
||||
const b = timeline.bounds;
|
||||
map.fitBounds(
|
||||
[
|
||||
[b.minLat, b.minLng],
|
||||
[b.maxLat, b.maxLng],
|
||||
],
|
||||
{
|
||||
padding: [50, 50],
|
||||
animate: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function refreshMap(currentPositions = positionsNow) {
|
||||
if (!map || !pathLayer || !markerLayer) return;
|
||||
pathLayer.clearLayers();
|
||||
markerLayer.clearLayers();
|
||||
|
||||
currentPositions.forEach((drone, idx) => {
|
||||
const color = colorFor(idx, palette);
|
||||
|
||||
// Draw Full Trajectory (Dimmed)
|
||||
if (drone.path.length > 1) {
|
||||
L.polyline(
|
||||
drone.path.map((p) => [p.lat, p.lng]),
|
||||
{
|
||||
color: color,
|
||||
weight: 2,
|
||||
opacity: 0.2,
|
||||
dashArray: "4, 8", // Dashed line for future path
|
||||
},
|
||||
).addTo(pathLayer);
|
||||
}
|
||||
|
||||
// Draw Visited Trajectory (Bright)
|
||||
if (drone.visited.length > 1) {
|
||||
L.polyline(
|
||||
drone.visited.map((p) => [p.lat, p.lng]),
|
||||
{
|
||||
color: color,
|
||||
weight: 3,
|
||||
opacity: 0.9,
|
||||
className: "neon-path", // Can add glow via CSS if desired
|
||||
},
|
||||
).addTo(pathLayer);
|
||||
}
|
||||
|
||||
// Draw Drone Marker
|
||||
if (drone.current) {
|
||||
const marker = L.circleMarker(
|
||||
[drone.current.lat, drone.current.lng],
|
||||
{
|
||||
radius: 6,
|
||||
color: "#fff",
|
||||
weight: 2,
|
||||
fillColor: color,
|
||||
fillOpacity: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const isMoving =
|
||||
drone.path.length > 1 &&
|
||||
drone.visited.length < drone.path.length;
|
||||
|
||||
const popupContent = `
|
||||
<div class="min-w-[120px]">
|
||||
<h3 class="font-bold text-sm mb-1" style="color: ${color}">Drone ${drone.id}</h3>
|
||||
<div class="text-[10px] space-y-0.5 font-mono opacity-80">
|
||||
<div class="flex justify-between"><span>Lat:</span> <span>${drone.current.lat.toFixed(4)}</span></div>
|
||||
<div class="flex justify-between"><span>Lng:</span> <span>${drone.current.lng.toFixed(4)}</span></div>
|
||||
<div class="flex justify-between mt-1 pt-1 border-t border-white/10">
|
||||
<span>Status:</span>
|
||||
<span class="${isMoving ? "text-green-400" : "text-slate-400"}">${isMoving ? "MOVING" : "IDLE"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Tooltip style
|
||||
marker.bindTooltip(`ID: ${drone.id}`, {
|
||||
permanent: false,
|
||||
direction: "top",
|
||||
className: "drone-tooltip", // Custom class
|
||||
});
|
||||
|
||||
marker.bindPopup(popupContent, {
|
||||
className: "drone-popup",
|
||||
closeButton: false,
|
||||
});
|
||||
|
||||
marker.addTo(markerLayer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
theme.update((t) => (t === "dark" ? "light" : "dark"));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
id="map-background"
|
||||
class="absolute inset-0 z-0 bg-gray-800"
|
||||
bind:this={mapContainer}
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="relative z-10 pointer-events-none h-screen w-screen grid transition-[grid-template-columns] duration-500 ease-in-out grid-rows-[1fr_auto]"
|
||||
style="grid-template-columns: {sidebarOpen ? '380px' : '0px'} 1fr;"
|
||||
>
|
||||
<!-- Sidebar Container -->
|
||||
<div class="row-span-full relative h-full">
|
||||
<!-- Collapsed State Toggle Button (Visible when sidebar is closed) -->
|
||||
<button
|
||||
on:click={() => (sidebarOpen = true)}
|
||||
class="pointer-events-auto absolute top-4 left-4 z-50 p-2.5 bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl border border-slate-200 dark:border-slate-800 shadow-lg rounded-xl text-slate-500 hover:text-sky-500 transition-all duration-300 {sidebarOpen
|
||||
? 'opacity-0 pointer-events-none -translate-x-full'
|
||||
: 'opacity-100 translate-x-0'}"
|
||||
aria-label="Open 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"
|
||||
><rect x="3" y="3" width="18" height="18" rx="2" ry="2"
|
||||
></rect><line x1="9" y1="3" x2="9" y2="21"></line></svg
|
||||
>
|
||||
</button>
|
||||
|
||||
<Sidebar
|
||||
bind:open={sidebarOpen}
|
||||
bind:apiBase
|
||||
bind:blackBoxBase
|
||||
bind:startTime
|
||||
bind:dispatchBody
|
||||
{status}
|
||||
{loading}
|
||||
on:close={() => (sidebarOpen = false)}
|
||||
on:request={requestPath}
|
||||
on:loadSample={loadSample}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PlaybackControls
|
||||
{wallClock}
|
||||
{playbackSeconds}
|
||||
totalCost={plannedPath?.totalCost}
|
||||
activeDrones={plannedPath?.dronePaths?.length}
|
||||
{isPlaying}
|
||||
bind:tick
|
||||
{totalSteps}
|
||||
{snapshotStatus}
|
||||
{snapshot}
|
||||
on:togglePlay={togglePlay}
|
||||
on:seek={stopPlaying}
|
||||
on:fetchSnapshot={fetchSnapshot}
|
||||
/>
|
||||
</div>
|
||||
58
drone-frontend/src/app.css
Normal file
58
drone-frontend/src/app.css
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
@import "leaflet/dist/leaflet.css";
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Light theme palette for drone paths - Distinct Colors */
|
||||
--p-1: theme("colors.green.600");
|
||||
--p-2: theme("colors.yellow.600");
|
||||
--p-3: theme("colors.sky.500");
|
||||
--p-4: theme("colors.pink.600");
|
||||
--p-5: theme("colors.violet.600");
|
||||
--p-6: theme("colors.red.600");
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark theme palette for drone paths - Neon Colors */
|
||||
--p-1: #00ff00;
|
||||
--p-2: #ffff00;
|
||||
--p-3: #00ffff;
|
||||
--p-4: #ff00ff;
|
||||
--p-5: #bd00ff;
|
||||
--p-6: #ff4d4d;
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
background: #111 !important;
|
||||
}
|
||||
|
||||
.drone-tooltip {
|
||||
@apply bg-gray-800 text-white p-2 rounded-md border-gray-700 border;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(148, 163, 184, 0.3); /* Slate 400 with opacity */
|
||||
border-radius: 20px;
|
||||
}
|
||||
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(148, 163, 184, 0.5);
|
||||
}
|
||||
|
||||
/* Drone Popup Styles */
|
||||
.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;
|
||||
}
|
||||
.drone-popup .leaflet-popup-tip {
|
||||
@apply bg-slate-900/90 border-slate-700;
|
||||
}
|
||||
.drone-popup .leaflet-popup-content {
|
||||
margin: 12px;
|
||||
}
|
||||
164
drone-frontend/src/components/PlaybackControls.svelte
Normal file
164
drone-frontend/src/components/PlaybackControls.svelte
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { theme } from "../stores.js";
|
||||
|
||||
export let wallClock = "";
|
||||
export let playbackSeconds = 0;
|
||||
export let totalCost = 0;
|
||||
export let activeDrones = 0;
|
||||
export let isPlaying = false;
|
||||
export let tick = 0;
|
||||
export let totalSteps = 0;
|
||||
export let snapshotStatus = "";
|
||||
export let snapshot = [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function formatDuration(sec) {
|
||||
const m = Math.floor(sec / 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const s = Math.floor(sec % 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="pointer-events-auto bg-white/70 dark:bg-slate-900/70 backdrop-blur-xl border border-slate-200 dark:border-slate-800 shadow-2xl flex flex-col gap-3 self-end row-start-2 col-start-2 m-6 ml-8 rounded-2xl p-4"
|
||||
>
|
||||
<div class="flex gap-6">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs uppercase text-slate-500 dark:text-slate-400"
|
||||
>Mission Time</span
|
||||
>
|
||||
<span
|
||||
class="font-mono text-xl font-bold text-slate-800 dark:text-slate-100"
|
||||
>{wallClock.split("T")[1].slice(0, 8)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs uppercase text-slate-500 dark:text-slate-400"
|
||||
>T+ Elapsed</span
|
||||
>
|
||||
<span
|
||||
class="font-mono text-xl font-bold text-slate-800 dark:text-slate-100"
|
||||
>{formatDuration(playbackSeconds)}</span
|
||||
>
|
||||
</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">
|
||||
<span class="text-xs uppercase text-slate-500 dark:text-slate-400"
|
||||
>Active Drones</span
|
||||
>
|
||||
<span
|
||||
class="font-mono text-xl font-bold text-slate-800 dark:text-slate-100"
|
||||
>{activeDrones ?? 0}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center shrink-0 bg-slate-800 text-white dark:bg-slate-100 dark:text-slate-900 hover:bg-slate-900 dark:hover:bg-white transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
on:click={() => dispatch('togglePlay')}
|
||||
disabled={totalSteps <= 1}
|
||||
>
|
||||
{#if isPlaying}
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"
|
||||
><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /></svg
|
||||
>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"
|
||||
><path d="M8 5v14l11-7z" /></svg
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={Math.max(totalSteps - 1, 0)}
|
||||
step="1"
|
||||
bind:value={tick}
|
||||
on:input={() => dispatch('seek')}
|
||||
class="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-full appearance-none"
|
||||
style="--thumb-color: {$theme === 'dark'
|
||||
? '#f1f5f9'
|
||||
: '#1e293b'};"
|
||||
/>
|
||||
|
||||
<span
|
||||
class="font-mono text-sm min-w-[60px] text-right text-slate-600 dark:text-slate-400"
|
||||
>
|
||||
{Math.round((tick / (totalSteps - 1 || 1)) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-2 pt-2 border-t border-slate-200 dark:border-slate-800 flex justify-between items-center"
|
||||
>
|
||||
<div
|
||||
class="text-xs text-slate-500 dark:text-slate-400 overflow-hidden whitespace-nowrap text-ellipsis max-w-[300px]"
|
||||
>
|
||||
{snapshotStatus || "Black Box Offline"}
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1 text-xs font-semibold rounded-md transition-all text-slate-800 dark:text-slate-100 bg-slate-500/20 hover:bg-slate-500/30"
|
||||
on:click={() => dispatch('fetchSnapshot')}
|
||||
>
|
||||
FETCH SNAPSHOT
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if snapshot.length > 0}
|
||||
<div class="flex flex-wrap gap-1.5 mt-2">
|
||||
{#each snapshot.slice(0, 3) as event}
|
||||
<div
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-slate-500/20 font-mono text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
Drone {event.drone_id} log found
|
||||
</div>
|
||||
{/each}
|
||||
{#if snapshot.length > 3}
|
||||
<div
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-slate-500/20 font-mono text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
+{snapshot.length - 3} more
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--thumb-color);
|
||||
margin-top: -7px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--thumb-color);
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
</style>
|
||||
209
drone-frontend/src/components/Sidebar.svelte
Normal file
209
drone-frontend/src/components/Sidebar.svelte
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { theme } from "../stores.js";
|
||||
|
||||
export let open = true;
|
||||
export let status = "";
|
||||
export let loading = false;
|
||||
|
||||
// Configuration Bindings
|
||||
export let apiBase = "";
|
||||
export let blackBoxBase = "";
|
||||
export let startTime = "";
|
||||
export let dispatchBody = "";
|
||||
|
||||
let configOpen = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function toggleTheme() {
|
||||
theme.update((t) => (t === "dark" ? "light" : "dark"));
|
||||
}
|
||||
</script>
|
||||
|
||||
<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%]'}"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1
|
||||
class="text-xl font-bold text-slate-800 dark:text-slate-100 flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="block w-2 h-2 bg-sky-500 rounded-full shadow-[0_0_10px_theme(colors.sky.500)]"
|
||||
></span>
|
||||
Drone Explorer
|
||||
</h1>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
on:click={toggleTheme}
|
||||
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="Toggle Theme"
|
||||
>
|
||||
{#if $theme === "dark"}
|
||||
<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"
|
||||
><path
|
||||
d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"
|
||||
></path></svg
|
||||
>
|
||||
{:else}
|
||||
<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"
|
||||
><circle cx="12" cy="12" r="5"></circle><line
|
||||
x1="12"
|
||||
y1="1"
|
||||
x2="12"
|
||||
y2="3"
|
||||
></line><line x1="12" y1="21" x2="12" y2="23"
|
||||
></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"
|
||||
></line><line
|
||||
x1="18.36"
|
||||
y1="18.36"
|
||||
x2="19.78"
|
||||
y2="19.78"
|
||||
></line><line x1="1" y1="12" x2="3" y2="12"></line><line
|
||||
x1="21"
|
||||
y1="12"
|
||||
x2="23"
|
||||
y2="12"
|
||||
></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"
|
||||
></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"
|
||||
></line></svg
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="font-mono text-xs text-slate-500 dark:text-slate-400 p-2.5 bg-slate-500/10 rounded-lg border-l-4 border-sky-500/50"
|
||||
>
|
||||
> {status}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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"
|
||||
on:click={() => (configOpen = !configOpen)}
|
||||
>
|
||||
<span>Configuration</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="transform transition-transform duration-300 {configOpen
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
><polyline points="6 9 12 15 18 9"></polyline></svg
|
||||
>
|
||||
</button>
|
||||
|
||||
{#if configOpen}
|
||||
<div class="mt-3 space-y-3">
|
||||
<div>
|
||||
<label
|
||||
for="api"
|
||||
class="block text-xs text-slate-600 dark:text-slate-400 mb-1"
|
||||
>Planner API Endpoint</label
|
||||
>
|
||||
<input
|
||||
id="api"
|
||||
type="text"
|
||||
bind:value={apiBase}
|
||||
placeholder="http://localhost..."
|
||||
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>
|
||||
<label
|
||||
for="bb"
|
||||
class="block text-xs text-slate-600 dark:text-slate-400 mb-1"
|
||||
>Black Box API Endpoint</label
|
||||
>
|
||||
<input
|
||||
id="bb"
|
||||
type="text"
|
||||
bind:value={blackBoxBase}
|
||||
placeholder="http://localhost..."
|
||||
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>
|
||||
<label
|
||||
for="startTime"
|
||||
class="block text-xs text-slate-600 dark:text-slate-400 mb-1"
|
||||
>Mission Start Time</label
|
||||
>
|
||||
<input
|
||||
id="startTime"
|
||||
type="datetime-local"
|
||||
bind:value={startTime}
|
||||
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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<h2
|
||||
class="mb-2 text-sm font-semibold tracking-wider uppercase text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
Dispatch Payload (JSON)
|
||||
</h2>
|
||||
<textarea
|
||||
class="flex-1 min-h-[200px] text-xs 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.5 rounded-lg font-mono 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 resize-none"
|
||||
bind:value={dispatchBody}
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-auto pt-4 border-t border-slate-200 dark:border-slate-800">
|
||||
<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"
|
||||
on:click={() => dispatch('request')}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "CALCULATING..." : "REQUEST PATH"}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 p-2.5 rounded-lg font-semibold text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed text-slate-800 dark:text-slate-100 bg-slate-500/20 hover:bg-slate-500/30"
|
||||
on:click={() => dispatch('loadSample')}
|
||||
>
|
||||
LOAD SAMPLE
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
8
drone-frontend/src/main.js
Normal file
8
drone-frontend/src/main.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import './app.css';
|
||||
import App from './App.svelte';
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById('app')
|
||||
});
|
||||
|
||||
export default app;
|
||||
1336
drone-frontend/src/sampleData.js
Normal file
1336
drone-frontend/src/sampleData.js
Normal file
File diff suppressed because it is too large
Load diff
3
drone-frontend/src/stores.js
Normal file
3
drone-frontend/src/stores.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export const theme = writable('dark');
|
||||
108
drone-frontend/src/utils.js
Normal file
108
drone-frontend/src/utils.js
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { fallbackBounds } from "./sampleData.js";
|
||||
|
||||
export function buildTimeline(response) {
|
||||
const drones = [];
|
||||
const allPoints = [];
|
||||
let longest = 0;
|
||||
|
||||
if (!response || !response.dronePaths) {
|
||||
return { drones, totalSteps: 0, bounds: fallbackBounds };
|
||||
}
|
||||
|
||||
for (const path of response.dronePaths) {
|
||||
const coords = flattenDeliveries(path.deliveries);
|
||||
if (!coords.length) continue;
|
||||
drones.push({ id: path.droneId, path: coords });
|
||||
coords.forEach((p) => allPoints.push(p));
|
||||
longest = Math.max(longest, coords.length);
|
||||
}
|
||||
|
||||
// Add padding points for bounds calculation if empty
|
||||
const basePoints =
|
||||
allPoints.length > 0
|
||||
? allPoints
|
||||
: [
|
||||
{
|
||||
lng: fallbackBounds.minLng,
|
||||
lat: fallbackBounds.minLat,
|
||||
},
|
||||
{
|
||||
lng: fallbackBounds.maxLng,
|
||||
lat: fallbackBounds.maxLat,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
drones,
|
||||
totalSteps: longest || 0,
|
||||
bounds: computeBounds(basePoints),
|
||||
};
|
||||
}
|
||||
|
||||
function flattenDeliveries(deliveries) {
|
||||
const coords = [];
|
||||
for (const delivery of deliveries || []) {
|
||||
for (const point of delivery.flightPath || []) {
|
||||
if (
|
||||
!coords.length ||
|
||||
!samePoint(coords[coords.length - 1], point)
|
||||
) {
|
||||
coords.push(point);
|
||||
}
|
||||
}
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
function samePoint(a, b) {
|
||||
return Math.abs(a.lng - b.lng) < 1e-9 && Math.abs(a.lat - b.lat) < 1e-9;
|
||||
}
|
||||
|
||||
export function computeBounds(points) {
|
||||
if (!points.length) return fallbackBounds;
|
||||
let minLng = Infinity,
|
||||
maxLng = -Infinity,
|
||||
minLat = Infinity,
|
||||
maxLat = -Infinity;
|
||||
for (const p of points) {
|
||||
minLng = Math.min(minLng, p.lng);
|
||||
maxLng = Math.max(maxLng, p.lng);
|
||||
minLat = Math.min(minLat, p.lat);
|
||||
maxLat = Math.max(maxLat, p.lat);
|
||||
}
|
||||
const padding = 0.002; // Slightly more padding
|
||||
return {
|
||||
minLng: minLng - padding,
|
||||
maxLng: maxLng + padding,
|
||||
minLat: minLat - padding,
|
||||
maxLat: maxLat + padding,
|
||||
};
|
||||
}
|
||||
|
||||
export function colorFor(idx, palette) {
|
||||
if (!palette || palette.length === 0) return "#000";
|
||||
return palette[idx % palette.length];
|
||||
}
|
||||
|
||||
export function formatDuration(sec) {
|
||||
const m = Math.floor(sec / 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const s = Math.floor(sec % 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
export function computeWallClock(start, seconds) {
|
||||
const base = start ? new Date(start) : new Date();
|
||||
const ts = new Date(base.getTime() + seconds * 1000);
|
||||
return isNaN(ts.getTime())
|
||||
? new Date().toISOString()
|
||||
: ts.toISOString();
|
||||
}
|
||||
|
||||
export function boundsKey(bounds) {
|
||||
if (!bounds) return "";
|
||||
return `${bounds.minLat}:${bounds.maxLat}:${bounds.minLng}:${bounds.maxLng}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue