Frontend Basics

This commit is contained in:
js0ny 2025-12-04 17:21:23 +00:00
parent fde16fa283
commit 3bbfed11fa
28 changed files with 2780 additions and 7 deletions

View 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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <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>

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

View 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>

View 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>

View file

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

File diff suppressed because it is too large Load diff

View file

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

108
drone-frontend/src/utils.js Normal file
View 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}`;
}