fix(frontend): will not auto-resize to fit

This commit is contained in:
js0ny 2025-12-06 03:54:04 +00:00
parent 3509c556a1
commit acf9d132f7
4 changed files with 217 additions and 23 deletions

View file

@ -12,8 +12,8 @@
import PlaybackControls from "./components/PlaybackControls.svelte";
import {
buildTimeline,
computeBounds,
computeWallClock,
boundsKey,
colorFor,
} from "./utils.js";
@ -48,7 +48,7 @@
let map;
let pathLayer;
let markerLayer;
let lastBoundsKey = "";
let currentBounds = fallbackBounds;
let apiBase = "http://localhost:8080/api/v1";
let blackBoxBase = "http://localhost:3000";
@ -58,34 +58,64 @@
let status = "System Ready. Waiting for dispatch payload.";
let snapshotStatus = "";
let snapshot = [];
let positionsNow = [];
let snapshotController;
let lastSnapshotTime = "";
let tick = 0;
let isPlaying = false;
let loading = false;
let playTimer;
let startTime = new Date().toISOString().slice(0, 16);
let startTime = "";
let desiredTime = "";
let sidebarOpen = true;
function deriveStartTime(payload) {
if (!Array.isArray(payload)) return null;
let earliest = Number.POSITIVE_INFINITY;
for (const rec of payload) {
if (!rec?.date || !rec?.time) continue;
const ts = Date.parse(`${rec.date}T${rec.time}`);
if (!Number.isNaN(ts)) {
earliest = Math.min(earliest, ts);
}
}
if (!Number.isFinite(earliest)) return null;
return new Date(earliest).toISOString().slice(0, 16);
}
startTime = deriveStartTime(defaultDispatch) || new Date().toISOString().slice(0, 16);
desiredTime = startTime;
function jumpToTime(timeValue) {
if (!timeValue) return;
const target = Date.parse(timeValue);
if (Number.isNaN(target)) return;
// Reset mission base to the chosen time so the progress bar reflects that date.
startTime = timeValue;
desiredTime = timeValue;
tick = 0;
fetchSnapshotForTime(new Date(target).toISOString());
}
// Reactive Statements
$: timeline = buildTimeline(plannedPath);
$: totalSteps = Math.max(timeline.totalSteps, 1);
$: totalSteps = Math.max(timeline.totalSteps, plannedPath?.totalMoves || 1, 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)),
}));
$: fetchSnapshotForTime(wallClock, true);
$: positionsNow = snapshotToPositions(snapshot);
$: currentBounds =
positionsNow.length > 0
? computeBounds(
positionsNow
.map((d) => d.current)
.filter((p) => p && typeof p.lat === "number" && typeof p.lng === "number"),
)
: timeline.bounds || fallbackBounds;
// Update Map
$: if (map && palette.length > 0) {
const nextKey = boundsKey(timeline.bounds);
if (nextKey && nextKey !== lastBoundsKey) {
fitMapToBounds();
lastBoundsKey = nextKey;
}
refreshMap(positionsNow);
}
@ -169,6 +199,12 @@
return;
}
const derivedStart = deriveStartTime(payload);
if (derivedStart) {
startTime = derivedStart;
desiredTime = derivedStart;
}
loading = true;
status = "Transmitting dispatch to API...";
stopPlaying();
@ -183,6 +219,7 @@
plannedPath = await res.json();
tick = 0;
fitMapToBounds();
status = "Flight path received. Ready for simulation.";
} catch (err) {
plannedPath = samplePathResponse;
@ -197,6 +234,12 @@
status = "Loaded sample simulation data.";
tick = 0;
stopPlaying();
fitMapToBounds();
const sampleStart = deriveStartTime(defaultDispatch);
if (sampleStart) {
startTime = sampleStart;
desiredTime = sampleStart;
}
}
function togglePlay() {
@ -218,23 +261,62 @@
}
async function fetchSnapshot() {
snapshotStatus = "Querying Black Box...";
await fetchSnapshotForTime(wallClock, false);
}
async function fetchSnapshotForTime(requestedTime = wallClock, silent = false) {
if (!requestedTime) return;
if (silent && requestedTime === lastSnapshotTime) return;
snapshotStatus = silent
? `Syncing snapshot for ${requestedTime}`
: `Querying Black Box for ${requestedTime}...`;
lastSnapshotTime = requestedTime;
if (snapshotController) snapshotController.abort();
const controller = new AbortController();
snapshotController = controller;
try {
const res = await fetch(
`${blackBoxBase}/snapshot?time=${encodeURIComponent(wallClock)}`,
`${blackBoxBase}/snapshot?time=${encodeURIComponent(requestedTime)}`,
{ signal: controller.signal },
);
if (!res.ok) throw new Error(res.status);
snapshot = await res.json();
snapshotStatus = `Retrieved ${snapshot.length} events for ${wallClock}`;
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
snapshot = data;
snapshotStatus = `Retrieved ${snapshot.length} events for ${requestedTime}`;
} else {
// Graceful fallback: project planned position if no events exist at the requested time.
const fallback = (timeline?.drones || [])
.map((d, idx) => {
const coords = d.path || [];
if (!coords.length) return null;
const pos = coords[Math.min(tick, coords.length - 1)];
return {
droneId: d.id ?? idx + 1,
latitude: pos.lat,
longitude: pos.lng,
timestamp: requestedTime,
};
})
.filter(Boolean);
snapshot = fallback;
snapshotStatus = `No telemetry at ${requestedTime}; showing planned positions.`;
}
} catch (err) {
if (err?.name === "AbortError") return;
snapshotStatus = `Connection Error: ${err.message}`;
snapshot = [];
lastSnapshotTime = "";
}
}
function fitMapToBounds() {
if (!map || !timeline.bounds) return;
const b = timeline.bounds;
if (!map || !currentBounds) return;
const b = currentBounds;
map.fitBounds(
[
[b.minLat, b.minLng],
@ -332,6 +414,60 @@
function toggleTheme() {
theme.update((t) => (t === "dark" ? "light" : "dark"));
}
function snapshotToPositions(data = snapshot) {
const timelinePaths = new Map(
(timeline?.drones || []).map((d) => [String(d.id), d.path || []]),
);
const findClosestIndex = (coords, point) => {
if (!coords?.length || !point) return -1;
let bestIdx = -1;
let bestDist = Number.POSITIVE_INFINITY;
coords.forEach((c, idx) => {
const dist =
Math.abs(c.lat - point.lat) + Math.abs(c.lng - point.lng);
if (dist < bestDist) {
bestDist = dist;
bestIdx = idx;
}
});
return bestIdx;
};
return data
.map((event, idx) => {
const lat =
typeof event.latitude === "number"
? event.latitude
: typeof event.lat === "number"
? event.lat
: null;
const lng =
typeof event.longitude === "number"
? event.longitude
: typeof event.lng === "number"
? event.lng
: null;
if (lat === null || lng === null) return null;
const id = event.droneId ?? event.drone_id ?? idx + 1;
const point = { lat, lng };
const coords = timelinePaths.get(String(id)) || [];
const matchedIdx = findClosestIndex(coords, point);
const visited =
matchedIdx >= 0
? coords.slice(0, matchedIdx + 1)
: [point];
const fullPath = coords.length ? coords : [point];
return {
id,
path: fullPath,
visited,
current: visited[visited.length - 1] || point,
};
})
.filter(Boolean);
}
</script>
<div
@ -393,8 +529,10 @@
{totalSteps}
{snapshotStatus}
{snapshot}
bind:desiredTime
on:togglePlay={togglePlay}
on:seek={stopPlaying}
on:fetchSnapshot={fetchSnapshot}
on:setTime={(e) => jumpToTime(e.detail)}
/>
</div>

View file

@ -11,6 +11,7 @@
export let totalSteps = 0;
export let snapshotStatus = "";
export let snapshot = [];
export let desiredTime = "";
const dispatch = createEventDispatcher();
@ -120,13 +121,31 @@
</button>
</div>
<div class="flex items-center gap-3 text-xs mt-1">
<label class="text-slate-500 dark:text-slate-400 uppercase tracking-wide"
>Jump to Time</label
>
<input
type="datetime-local"
bind:value={desiredTime}
class="bg-white/60 dark:bg-black/30 border border-slate-300 dark:border-slate-700 rounded-md px-2 py-1 font-mono text-xs text-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500"
on:change={(e) => dispatch('setTime', e.target.value)}
/>
<button
class="px-3 py-1 text-xs font-semibold rounded-md transition-all text-slate-800 dark:text-slate-100 bg-sky-500/20 hover:bg-sky-500/30"
on:click={() => dispatch('setTime', desiredTime)}
>
GO
</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
Drone {event.droneId ?? event.drone_id ?? "?"} log found
</div>
{/each}
{#if snapshot.length > 3}