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

@ -84,6 +84,8 @@ func (s *Server) snapshotHandler(w http.ResponseWriter, r *http.Request) {
results = append(results, event) results = append(results, event)
} }
slog.Info("Snapshot retrieved", "time", timeParam, "count", len(results))
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(results); err != nil { if err := json.NewEncoder(w).Encode(results); err != nil {
slog.Error("Failed to encode response", "error", err) slog.Error("Failed to encode response", "error", err)

View file

@ -12,8 +12,8 @@
import PlaybackControls from "./components/PlaybackControls.svelte"; import PlaybackControls from "./components/PlaybackControls.svelte";
import { import {
buildTimeline, buildTimeline,
computeBounds,
computeWallClock, computeWallClock,
boundsKey,
colorFor, colorFor,
} from "./utils.js"; } from "./utils.js";
@ -48,7 +48,7 @@
let map; let map;
let pathLayer; let pathLayer;
let markerLayer; let markerLayer;
let lastBoundsKey = ""; 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";
@ -58,34 +58,64 @@
let status = "System Ready. Waiting for dispatch payload."; let status = "System Ready. Waiting for dispatch payload.";
let snapshotStatus = ""; let snapshotStatus = "";
let snapshot = []; let snapshot = [];
let positionsNow = [];
let snapshotController;
let lastSnapshotTime = "";
let tick = 0; let tick = 0;
let isPlaying = false; let isPlaying = false;
let loading = false; let loading = false;
let playTimer; let playTimer;
let startTime = new Date().toISOString().slice(0, 16); let startTime = "";
let desiredTime = "";
let sidebarOpen = true; 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 // Reactive Statements
$: timeline = buildTimeline(plannedPath); $: timeline = buildTimeline(plannedPath);
$: totalSteps = Math.max(timeline.totalSteps, 1); $: totalSteps = Math.max(timeline.totalSteps, plannedPath?.totalMoves || 1, 1);
$: tick = Math.min(tick, totalSteps - 1); $: tick = Math.min(tick, totalSteps - 1);
$: playbackSeconds = tick * STEP_SECONDS; $: playbackSeconds = tick * STEP_SECONDS;
$: wallClock = computeWallClock(startTime, playbackSeconds); $: wallClock = computeWallClock(startTime, playbackSeconds);
$: fetchSnapshotForTime(wallClock, true);
// Compute positions based on current tick $: positionsNow = snapshotToPositions(snapshot);
$: positionsNow = timeline.drones.map((drone) => ({ $: currentBounds =
...drone, positionsNow.length > 0
current: drone.path[Math.min(tick, drone.path.length - 1)], ? computeBounds(
visited: drone.path.slice(0, Math.min(tick + 1, drone.path.length)), positionsNow
})); .map((d) => d.current)
.filter((p) => p && typeof p.lat === "number" && typeof p.lng === "number"),
)
: timeline.bounds || fallbackBounds;
// Update Map // Update Map
$: if (map && palette.length > 0) { $: if (map && palette.length > 0) {
const nextKey = boundsKey(timeline.bounds);
if (nextKey && nextKey !== lastBoundsKey) {
fitMapToBounds();
lastBoundsKey = nextKey;
}
refreshMap(positionsNow); refreshMap(positionsNow);
} }
@ -169,6 +199,12 @@
return; return;
} }
const derivedStart = deriveStartTime(payload);
if (derivedStart) {
startTime = derivedStart;
desiredTime = derivedStart;
}
loading = true; loading = true;
status = "Transmitting dispatch to API..."; status = "Transmitting dispatch to API...";
stopPlaying(); stopPlaying();
@ -183,6 +219,7 @@
plannedPath = await res.json(); plannedPath = await res.json();
tick = 0; tick = 0;
fitMapToBounds();
status = "Flight path received. Ready for simulation."; status = "Flight path received. Ready for simulation.";
} catch (err) { } catch (err) {
plannedPath = samplePathResponse; plannedPath = samplePathResponse;
@ -197,6 +234,12 @@
status = "Loaded sample simulation data."; status = "Loaded sample simulation data.";
tick = 0; tick = 0;
stopPlaying(); stopPlaying();
fitMapToBounds();
const sampleStart = deriveStartTime(defaultDispatch);
if (sampleStart) {
startTime = sampleStart;
desiredTime = sampleStart;
}
} }
function togglePlay() { function togglePlay() {
@ -218,23 +261,62 @@
} }
async function fetchSnapshot() { 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 { try {
const res = await fetch( 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); if (!res.ok) throw new Error(res.status);
snapshot = await res.json(); const data = await res.json();
snapshotStatus = `Retrieved ${snapshot.length} events for ${wallClock}`; 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) { } catch (err) {
if (err?.name === "AbortError") return;
snapshotStatus = `Connection Error: ${err.message}`; snapshotStatus = `Connection Error: ${err.message}`;
snapshot = []; snapshot = [];
lastSnapshotTime = "";
} }
} }
function fitMapToBounds() { function fitMapToBounds() {
if (!map || !timeline.bounds) return; if (!map || !currentBounds) return;
const b = timeline.bounds; const b = currentBounds;
map.fitBounds( map.fitBounds(
[ [
[b.minLat, b.minLng], [b.minLat, b.minLng],
@ -332,6 +414,60 @@
function toggleTheme() { function toggleTheme() {
theme.update((t) => (t === "dark" ? "light" : "dark")); 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> </script>
<div <div
@ -393,8 +529,10 @@
{totalSteps} {totalSteps}
{snapshotStatus} {snapshotStatus}
{snapshot} {snapshot}
bind:desiredTime
on:togglePlay={togglePlay} on:togglePlay={togglePlay}
on:seek={stopPlaying} on:seek={stopPlaying}
on:fetchSnapshot={fetchSnapshot} on:fetchSnapshot={fetchSnapshot}
on:setTime={(e) => jumpToTime(e.detail)}
/> />
</div> </div>

View file

@ -11,6 +11,7 @@
export let totalSteps = 0; export let totalSteps = 0;
export let snapshotStatus = ""; export let snapshotStatus = "";
export let snapshot = []; export let snapshot = [];
export let desiredTime = "";
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -120,13 +121,31 @@
</button> </button>
</div> </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} {#if snapshot.length > 0}
<div class="flex flex-wrap gap-1.5 mt-2"> <div class="flex flex-wrap gap-1.5 mt-2">
{#each snapshot.slice(0, 3) as event} {#each snapshot.slice(0, 3) as event}
<div <div
class="text-xs px-2 py-0.5 rounded-full bg-slate-500/20 font-mono text-slate-600 dark:text-slate-300" 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> </div>
{/each} {/each}
{#if snapshot.length > 3} {#if snapshot.length > 3}

View file

@ -0,0 +1,35 @@
meta {
name: Imperative
type: http
seq: 3
}
post {
url: {{API_BASE}}/calcDeliveryPath
body: json
auth: inherit
}
body:json {
[
{
"id": 5,
"date": "2025-11-22",
"time": "09:30",
"requirements": {
"capacity": 0.75,
"heating": false,
"maxCost": 13.5
},
"delivery": {
"lng": -3.167074009381139,
"lat": 55.94740195123114
}
}
]
}
settings {
encodeUrl: true
timeout: 0
}