feat: polish #13

Open
a.antonov wants to merge 8 commits from polish into components
7 changed files with 173 additions and 41 deletions
Showing only changes of commit 63f79f8e56 - Show all commits

View file

@ -73,6 +73,7 @@
</div> </div>
<div class="flex-fill d-flex flex-column"> <div class="flex-fill d-flex flex-column">
<div class="range-wrapper">
<input <input
type="range" type="range"
class="form-range" class="form-range"
@ -82,6 +83,19 @@
value={$timelineStore.time} value={$timelineStore.time}
oninput={onSeek} oninput={onSeek}
disabled={!hasData} /> 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);

View file

@ -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';

View file

@ -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();

View file

@ -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 cached = plotCache.get(name);
if (!cached || cached.result !== w.result || cached.color !== w.color || cached.opacity !== w.opacity) {
const scene = map.scene(name); const scene = map.scene(name);
plotPrediction(scene, w.result, { color: w.color, opacity: w.opacity }); plotPrediction(scene, w.result, { color: w.color, opacity: w.opacity });
ownedPlotScenes.add(name); 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);
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]); 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>

View file

@ -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';

View file

@ -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,

View file

@ -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',