fixed timeline and added qol
This commit is contained in:
parent
79e20ca37c
commit
63f79f8e56
7 changed files with 173 additions and 41 deletions
|
|
@ -73,15 +73,29 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-fill d-flex flex-column">
|
<div class="flex-fill d-flex flex-column">
|
||||||
<input
|
<div class="range-wrapper">
|
||||||
type="range"
|
<input
|
||||||
class="form-range"
|
type="range"
|
||||||
min={$timelineStore.min}
|
class="form-range"
|
||||||
max={$timelineStore.max}
|
min={$timelineStore.min}
|
||||||
step="1000"
|
max={$timelineStore.max}
|
||||||
value={$timelineStore.time}
|
step="1000"
|
||||||
oninput={onSeek}
|
value={$timelineStore.time}
|
||||||
disabled={!hasData} />
|
oninput={onSeek}
|
||||||
|
disabled={!hasData} />
|
||||||
|
{#if hasData && $timelineStore.markers.length > 0}
|
||||||
|
<div class="marker-ticks">
|
||||||
|
{#each $timelineStore.markers as m}
|
||||||
|
{@const pct = $timelineStore.max > 0 ? m.time / $timelineStore.max : 0}
|
||||||
|
<span
|
||||||
|
class="marker-tick"
|
||||||
|
style="left: calc({pct} * (100% - 1rem) + 0.5rem); --tick-color: {m.color}">
|
||||||
|
<span class="marker-tooltip">{fmtHms(m.time)}</span>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<div class="d-flex justify-content-between small font-monospace text-muted">
|
<div class="d-flex justify-content-between small font-monospace text-muted">
|
||||||
<span>{fmtHms(elapsed)}</span>
|
<span>{fmtHms(elapsed)}</span>
|
||||||
<span>{fmtHms(duration)}</span>
|
<span>{fmtHms(duration)}</span>
|
||||||
|
|
@ -107,6 +121,60 @@
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.range-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-ticks {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-tick {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--tick-color, #dc3545);
|
||||||
|
border-radius: 1px;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-tooltip {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--tick-color, #dc3545);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1010;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-tooltip::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-top-color: var(--tick-color, #dc3545);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-tick:hover .marker-tooltip {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.timeline-container {
|
.timeline-container {
|
||||||
min-width: calc(100vw - 24px);
|
min-width: calc(100vw - 24px);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export { timelineStore } from './store';
|
export { timelineStore } from './store';
|
||||||
export type { TimelineState } from './store';
|
export type { TimelineState, TimelineMarker } from './store';
|
||||||
export { default as TimeLine } from './TimeLine.svelte';
|
export { default as TimeLine } from './TimeLine.svelte';
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,18 @@ import { writable } from 'svelte/store';
|
||||||
* locally, but the UI slider always operates over the global range.
|
* locally, but the UI slider always operates over the global range.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface TimelineMarker {
|
||||||
|
time: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TimelineState {
|
export interface TimelineState {
|
||||||
time: number;
|
time: number;
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
speed: number;
|
speed: number;
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
|
markers: TimelineMarker[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const initial: TimelineState = {
|
const initial: TimelineState = {
|
||||||
|
|
@ -25,6 +31,7 @@ const initial: TimelineState = {
|
||||||
max: 0,
|
max: 0,
|
||||||
speed: 1,
|
speed: 1,
|
||||||
playing: false,
|
playing: false,
|
||||||
|
markers: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function createTimeline() {
|
function createTimeline() {
|
||||||
|
|
@ -87,7 +94,11 @@ function createTimeline() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { subscribe: store.subscribe, play, pause, reset, seek, setSpeed, setRange };
|
function setMarkers(markers: TimelineMarker[]) {
|
||||||
|
store.update((s) => ({ ...s, markers }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subscribe: store.subscribe, play, pause, reset, seek, setSpeed, setRange, setMarkers };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const timelineStore = createTimeline();
|
export const timelineStore = createTimeline();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { getMap, plotPrediction, plotAnimatedMarker } from '$map';
|
import { getMap, plotPrediction, plotAnimatedMarker, plotEndMarker } from '$map';
|
||||||
import { timelineStore } from '$features/timeline/store';
|
import { timelineStore } from '$features/timeline/store';
|
||||||
import { workspacesStore } from './store';
|
import { workspacesStore } from './store';
|
||||||
import type { Workspace } from './types';
|
import type { Workspace } from './types';
|
||||||
|
|
@ -22,25 +22,34 @@
|
||||||
// belong to workspaces which were removed from the store since last tick.
|
// belong to workspaces which were removed from the store since last tick.
|
||||||
const ownedPlotScenes = new Set<string>();
|
const ownedPlotScenes = new Set<string>();
|
||||||
const ownedCursorScenes = new Set<string>();
|
const ownedCursorScenes = 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 sceneName = (w: Workspace) => `ws/${w.id}`;
|
||||||
const cursorName = (w: Workspace) => `cursor/${w.id}`;
|
const cursorName = (w: Workspace) => `cursor/${w.id}`;
|
||||||
|
|
||||||
function updateGlobalRange(items: Workspace[]) {
|
function updateGlobalRange(items: Workspace[]) {
|
||||||
let min = Infinity;
|
let maxDuration = 0;
|
||||||
let max = -Infinity;
|
const entries: Array<{ duration: number; color: string }> = [];
|
||||||
for (const w of items) {
|
for (const w of items) {
|
||||||
if (!w.visible || !w.result) continue;
|
if (!w.visible || !w.result) continue;
|
||||||
const l = w.result.launch.datetime.getTime();
|
const duration =
|
||||||
const r = w.result.landing.datetime.getTime();
|
w.result.landing.datetime.getTime() - w.result.launch.datetime.getTime();
|
||||||
if (l < min) min = l;
|
if (duration > maxDuration) maxDuration = duration;
|
||||||
if (r > max) max = r;
|
entries.push({ duration, color: w.color });
|
||||||
}
|
|
||||||
if (!isFinite(min) || !isFinite(max)) {
|
|
||||||
timelineStore.setRange(0, 0);
|
|
||||||
} else {
|
|
||||||
timelineStore.setRange(min, max);
|
|
||||||
}
|
}
|
||||||
|
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[]) {
|
function renderAll(items: Workspace[]) {
|
||||||
|
|
@ -54,28 +63,39 @@
|
||||||
if (ownedPlotScenes.has(name)) {
|
if (ownedPlotScenes.has(name)) {
|
||||||
map.disposeScene(name);
|
map.disposeScene(name);
|
||||||
ownedPlotScenes.delete(name);
|
ownedPlotScenes.delete(name);
|
||||||
|
plotCache.delete(name);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const scene = map.scene(name);
|
const cached = plotCache.get(name);
|
||||||
plotPrediction(scene, w.result, { color: w.color, opacity: w.opacity });
|
if (!cached || cached.result !== w.result || cached.color !== w.color || cached.opacity !== w.opacity) {
|
||||||
ownedPlotScenes.add(name);
|
const scene = map.scene(name);
|
||||||
|
plotPrediction(scene, w.result, { color: w.color, opacity: w.opacity });
|
||||||
|
ownedPlotScenes.add(name);
|
||||||
|
plotCache.set(name, { result: w.result, color: w.color, opacity: w.opacity });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const name of Array.from(ownedPlotScenes)) {
|
for (const name of Array.from(ownedPlotScenes)) {
|
||||||
if (!live.has(name)) {
|
if (!live.has(name)) {
|
||||||
map.disposeScene(name);
|
map.disposeScene(name);
|
||||||
ownedPlotScenes.delete(name);
|
ownedPlotScenes.delete(name);
|
||||||
|
plotCache.delete(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function positionAt(path: LatLngTuple[], time: number, launchMs: number, landingMs: number) {
|
function positionAt(path: LatLngTuple[], elapsed: number, durationMs: number): LatLngTuple | null {
|
||||||
if (path.length === 0) return null;
|
if (path.length === 0) return null;
|
||||||
if (launchMs === landingMs) return path[0];
|
if (durationMs === 0) return path[0];
|
||||||
const t = Math.max(0, Math.min(1, (time - launchMs) / (landingMs - launchMs)));
|
const t = Math.max(0, Math.min(1, elapsed / durationMs));
|
||||||
const idx = Math.min(path.length - 1, Math.floor(t * (path.length - 1)));
|
const raw = t * (path.length - 1);
|
||||||
return path[idx];
|
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) {
|
function renderCursors(items: Workspace[], time: number) {
|
||||||
|
|
@ -89,18 +109,32 @@
|
||||||
if (ownedCursorScenes.has(name)) {
|
if (ownedCursorScenes.has(name)) {
|
||||||
map.disposeScene(name);
|
map.disposeScene(name);
|
||||||
ownedCursorScenes.delete(name);
|
ownedCursorScenes.delete(name);
|
||||||
|
doneCursorScenes.delete(name);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const p = positionAt(
|
const durationMs =
|
||||||
w.result.flight_path,
|
w.result.landing.datetime.getTime() - w.result.launch.datetime.getTime();
|
||||||
time,
|
const p = positionAt(w.result.flight_path, time, durationMs);
|
||||||
w.result.launch.datetime.getTime(),
|
|
||||||
w.result.landing.datetime.getTime(),
|
|
||||||
);
|
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
const scene = map.scene(name);
|
const scene = map.scene(name);
|
||||||
plotAnimatedMarker(scene, p[1], p[0]);
|
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);
|
ownedCursorScenes.add(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,6 +142,7 @@
|
||||||
if (!live.has(name)) {
|
if (!live.has(name)) {
|
||||||
map.disposeScene(name);
|
map.disposeScene(name);
|
||||||
ownedCursorScenes.delete(name);
|
ownedCursorScenes.delete(name);
|
||||||
|
doneCursorScenes.delete(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -128,5 +163,6 @@
|
||||||
for (const name of ownedCursorScenes) map.disposeScene(name);
|
for (const name of ownedCursorScenes) map.disposeScene(name);
|
||||||
ownedPlotScenes.clear();
|
ownedPlotScenes.clear();
|
||||||
ownedCursorScenes.clear();
|
ownedCursorScenes.clear();
|
||||||
|
doneCursorScenes.clear();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export * from './core';
|
export * from './core';
|
||||||
export { createMapLibreMap } from './maplibre';
|
export { createMapLibreMap } from './maplibre';
|
||||||
export { plotPrediction, plotTelemetry, plotAnimatedMarker } from './layers';
|
export { plotPrediction, plotTelemetry, plotAnimatedMarker, plotEndMarker } from './layers';
|
||||||
export type { TrajectoryStyle } from './layers';
|
export type { TrajectoryStyle } from './layers';
|
||||||
export { startCoordinateSelection } from './tools/selection';
|
export { startCoordinateSelection } from './tools/selection';
|
||||||
export { startMeasure } from './tools/measure';
|
export { startMeasure } from './tools/measure';
|
||||||
|
|
|
||||||
|
|
@ -104,8 +104,17 @@ export function plotTelemetry(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function plotEndMarker(scene: Scene, lng: number, lat: number): void {
|
||||||
|
scene.addCircle('marker-core', {
|
||||||
|
center: [lng, lat],
|
||||||
|
radiusPx: 7,
|
||||||
|
color: '#6c757d',
|
||||||
|
strokeColor: '#ffffff',
|
||||||
|
strokeWidth: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function plotAnimatedMarker(scene: Scene, lng: number, lat: number): void {
|
export function plotAnimatedMarker(scene: Scene, lng: number, lat: number): void {
|
||||||
scene.clear();
|
|
||||||
scene.addCircle('marker-ring', {
|
scene.addCircle('marker-ring', {
|
||||||
center: [lng, lat],
|
center: [lng, lat],
|
||||||
radiusPx: 14,
|
radiusPx: 14,
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,15 @@ class MapLibreScene implements Scene {
|
||||||
|
|
||||||
addCircle(id: string, options: CircleOptions): MapLayer {
|
addCircle(id: string, options: CircleOptions): MapLayer {
|
||||||
const layerId = this.scopeId(id);
|
const layerId = this.scopeId(id);
|
||||||
if (this.map.getSource(layerId)) this.remove(id);
|
const existing = this.map.getSource(layerId) as maplibregl.GeoJSONSource | undefined;
|
||||||
|
if (existing) {
|
||||||
|
existing.setData({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {},
|
||||||
|
geometry: { type: 'Point', coordinates: options.center },
|
||||||
|
});
|
||||||
|
return { id: layerId, remove: () => this.remove(id) };
|
||||||
|
}
|
||||||
|
|
||||||
this.map.addSource(layerId, {
|
this.map.addSource(layerId, {
|
||||||
type: 'geojson',
|
type: 'geojson',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue