212 lines
6.8 KiB
Svelte
212 lines
6.8 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy } from 'svelte';
|
|
import { getMap, plotPrediction, plotAnimatedMarker, plotEndMarker, plotBoundingBox } from '$map';
|
|
import { timelineStore } from '$features/timeline/store';
|
|
import { workspacesStore } from './store';
|
|
import type { Workspace } from './types';
|
|
import { computeBoundingBox, DEFAULT_BBOX_MARGIN_KM, type LatLngTuple } from '$domain';
|
|
|
|
/**
|
|
* Renders every workspace onto the shared map. Each workspace gets its own
|
|
* named scene (`ws/<id>`) so its layers can be cleared independently, plus
|
|
* a `cursor/<id>` scene for the animated playback marker.
|
|
*
|
|
* The component is placed as a child of <Map /> so `getMap()` returns a
|
|
* non-null instance via context.
|
|
*/
|
|
|
|
const map = getMap();
|
|
if (!map) throw new Error('WorkspaceRenderer must be a descendant of <Map />');
|
|
|
|
// Track the scenes we currently own so we can dispose the ones that
|
|
// belong to workspaces which were removed from the store since last tick.
|
|
const ownedPlotScenes = new Set<string>();
|
|
const ownedCursorScenes = new Set<string>();
|
|
const ownedBboxScenes = new Set<string>();
|
|
// Last-rendered state per workspace scene — skip re-plot when nothing changed.
|
|
const plotCache = new Map<string, { result: unknown; color: string; opacity: number }>();
|
|
// Cursor scenes that have reached their flight end and show a static end marker.
|
|
const doneCursorScenes = new Set<string>();
|
|
|
|
const sceneName = (w: Workspace) => `ws/${w.id}`;
|
|
const cursorName = (w: Workspace) => `cursor/${w.id}`;
|
|
const bboxName = (w: Workspace) => `bbox/${w.id}`;
|
|
|
|
function updateGlobalRange(items: Workspace[]) {
|
|
let maxDuration = 0;
|
|
const entries: Array<{ duration: number; color: string }> = [];
|
|
for (const w of items) {
|
|
if (!w.visible || !w.result) continue;
|
|
const duration =
|
|
w.result.landing.datetime.getTime() - w.result.launch.datetime.getTime();
|
|
if (duration > maxDuration) maxDuration = duration;
|
|
entries.push({ duration, color: w.color });
|
|
}
|
|
timelineStore.setRange(0, maxDuration);
|
|
const seen = new Set<number>();
|
|
const markers = entries
|
|
.filter(({ duration }) => {
|
|
if (duration >= maxDuration || seen.has(duration)) return false;
|
|
seen.add(duration);
|
|
return true;
|
|
})
|
|
.map(({ duration, color }) => ({ time: duration, color }));
|
|
timelineStore.setMarkers(markers);
|
|
}
|
|
|
|
function renderAll(items: Workspace[]) {
|
|
if (!map) return;
|
|
|
|
const live = new Set<string>();
|
|
for (const w of items) {
|
|
const name = sceneName(w);
|
|
live.add(name);
|
|
if (!w.visible || !w.result) {
|
|
if (ownedPlotScenes.has(name)) {
|
|
map.disposeScene(name);
|
|
ownedPlotScenes.delete(name);
|
|
plotCache.delete(name);
|
|
}
|
|
continue;
|
|
}
|
|
const cached = plotCache.get(name);
|
|
if (!cached || cached.result !== w.result || cached.color !== w.color || cached.opacity !== w.opacity) {
|
|
const scene = map.scene(name);
|
|
plotPrediction(scene, w.result, { color: w.color, opacity: w.opacity });
|
|
// Frame a freshly-arrived result. A flight spans only tens of km,
|
|
// which is a few dozen pixels at the default camera height — the
|
|
// track ends up completely hidden under its own launch/burst/
|
|
// landing markers and reads as a single dot. Deliberately not done
|
|
// for colour/opacity edits, so tweaking one workspace while
|
|
// comparing several does not yank the camera around.
|
|
if (!cached || cached.result !== w.result) {
|
|
map.fitBounds(w.result.flight_path, 50);
|
|
}
|
|
ownedPlotScenes.add(name);
|
|
plotCache.set(name, { result: w.result, color: w.color, opacity: w.opacity });
|
|
}
|
|
}
|
|
|
|
for (const name of Array.from(ownedPlotScenes)) {
|
|
if (!live.has(name)) {
|
|
map.disposeScene(name);
|
|
ownedPlotScenes.delete(name);
|
|
plotCache.delete(name);
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderBboxes(items: Workspace[]) {
|
|
if (!map) return;
|
|
|
|
const live = new Set<string>();
|
|
for (const w of items) {
|
|
const name = bboxName(w);
|
|
live.add(name);
|
|
const box =
|
|
w.visible && w.result && w.bboxVisible
|
|
? computeBoundingBox(w.result.flight_path, w.bboxMargin ?? DEFAULT_BBOX_MARGIN_KM)
|
|
: null;
|
|
if (!box) {
|
|
if (ownedBboxScenes.has(name)) {
|
|
map.disposeScene(name);
|
|
ownedBboxScenes.delete(name);
|
|
}
|
|
continue;
|
|
}
|
|
plotBoundingBox(map.scene(name), box);
|
|
ownedBboxScenes.add(name);
|
|
}
|
|
|
|
for (const name of Array.from(ownedBboxScenes)) {
|
|
if (!live.has(name)) {
|
|
map.disposeScene(name);
|
|
ownedBboxScenes.delete(name);
|
|
}
|
|
}
|
|
}
|
|
|
|
function positionAt(path: LatLngTuple[], elapsed: number, durationMs: number): LatLngTuple | null {
|
|
if (path.length === 0) return null;
|
|
if (durationMs === 0) return path[0];
|
|
const t = Math.max(0, Math.min(1, elapsed / durationMs));
|
|
const raw = t * (path.length - 1);
|
|
const idx = Math.floor(raw);
|
|
if (idx >= path.length - 1) return path[path.length - 1];
|
|
const frac = raw - idx;
|
|
const a = path[idx];
|
|
const b = path[idx + 1];
|
|
return [a[0] + (b[0] - a[0]) * frac, a[1] + (b[1] - a[1]) * frac] as LatLngTuple;
|
|
}
|
|
|
|
function renderCursors(items: Workspace[], time: number) {
|
|
if (!map) return;
|
|
|
|
const live = new Set<string>();
|
|
for (const w of items) {
|
|
const name = cursorName(w);
|
|
live.add(name);
|
|
if (!w.visible || !w.result) {
|
|
if (ownedCursorScenes.has(name)) {
|
|
map.disposeScene(name);
|
|
ownedCursorScenes.delete(name);
|
|
doneCursorScenes.delete(name);
|
|
}
|
|
continue;
|
|
}
|
|
const durationMs =
|
|
w.result.landing.datetime.getTime() - w.result.launch.datetime.getTime();
|
|
const p = positionAt(w.result.flight_path, time, durationMs);
|
|
if (!p) continue;
|
|
const scene = map.scene(name);
|
|
const done = time >= durationMs;
|
|
if (done) {
|
|
if (!doneCursorScenes.has(name)) {
|
|
// Transition into done state: swap to static end marker.
|
|
scene.clear();
|
|
plotEndMarker(scene, p[1], p[0]);
|
|
doneCursorScenes.add(name);
|
|
}
|
|
// Position is clamped to landing — nothing more to update.
|
|
} else {
|
|
if (doneCursorScenes.has(name)) {
|
|
// Transition back to active (user seeked backwards).
|
|
scene.clear();
|
|
doneCursorScenes.delete(name);
|
|
}
|
|
plotAnimatedMarker(scene, p[1], p[0]);
|
|
}
|
|
ownedCursorScenes.add(name);
|
|
}
|
|
|
|
for (const name of Array.from(ownedCursorScenes)) {
|
|
if (!live.has(name)) {
|
|
map.disposeScene(name);
|
|
ownedCursorScenes.delete(name);
|
|
doneCursorScenes.delete(name);
|
|
}
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
const items = $workspacesStore.items;
|
|
renderAll(items);
|
|
renderBboxes(items);
|
|
updateGlobalRange(items);
|
|
});
|
|
|
|
$effect(() => {
|
|
renderCursors($workspacesStore.items, $timelineStore.time);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (!map) return;
|
|
for (const name of ownedPlotScenes) map.disposeScene(name);
|
|
for (const name of ownedCursorScenes) map.disposeScene(name);
|
|
for (const name of ownedBboxScenes) map.disposeScene(name);
|
|
ownedPlotScenes.clear();
|
|
ownedCursorScenes.clear();
|
|
ownedBboxScenes.clear();
|
|
doneCursorScenes.clear();
|
|
});
|
|
</script>
|