Skip to content

Commit 94cd546

Browse files
committed
feat: show TLE epoch age per source with styled tooltip
Replace cache age display with average TLE epoch age per data source. Hovering shows a detailed breakdown (newest, P25, median, average, P75, oldest) plus fetch time when available. Add reusable tooltip Svelte action (use:tooltip) with HTML support, replacing native title attrs.
1 parent a362474 commit 94cd546

4 files changed

Lines changed: 235 additions & 13 deletions

File tree

src/app.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { fetchTLEData, parseSatelliteDataParallel, warmupTLEWorkers, evictExpire
3131
import { cachePut, cleanupLocalStorage } from './data/cache-db';
3232
import { getSatellitesByFreqRange } from './data/satnogs';
3333
import { loadStdmag, loadSatnogs, applyStdmag, onStdmagRefresh } from './data/catalog';
34-
import { sourcesStore, type TLESourceConfig } from './stores/sources.svelte';
34+
import { sourcesStore, type TLESourceConfig, type EpochAgeStats } from './stores/sources.svelte';
3535
import { timeStore } from './stores/time.svelte';
3636
import { uiStore } from './stores/ui.svelte';
3737
import { beamStore } from './stores/beam.svelte';
@@ -567,6 +567,23 @@ export class App {
567567
this.mergeAndApply(enabledIdSet);
568568
}
569569

570+
private computeEpochAge(satellites: Satellite[]): EpochAgeStats | undefined {
571+
if (satellites.length === 0) return undefined;
572+
const nowUnix = Date.now() / 1000;
573+
const ages = satellites.map(s => (nowUnix - epochToUnix(s.epochDays)) * 1000);
574+
ages.sort((a, b) => a - b);
575+
const sum = ages.reduce((a, b) => a + b, 0);
576+
const p = (frac: number) => ages[Math.min(Math.floor(frac * ages.length), ages.length - 1)];
577+
return {
578+
avgMs: sum / ages.length,
579+
newestMs: ages[0],
580+
oldestMs: ages[ages.length - 1],
581+
p25Ms: p(0.25),
582+
p50Ms: p(0.5),
583+
p75Ms: p(0.75),
584+
};
585+
}
586+
570587
private async fetchSource(src: TLESourceConfig) {
571588
sourcesStore.setLoadState(src.id, { satCount: 0, status: 'loading' });
572589
try {
@@ -575,7 +592,7 @@ export class App {
575592
const result = await fetchTLEData(src.group!);
576593
satellites = result.satellites;
577594
const age = result.cacheAge;
578-
sourcesStore.setLoadState(src.id, { satCount: satellites.length, status: 'loaded', cacheAge: age });
595+
sourcesStore.setLoadState(src.id, { satCount: satellites.length, status: 'loaded', cacheAge: age, epochAge: this.computeEpochAge(satellites) });
579596
} else if (src.type === 'url') {
580597
const controller = new AbortController();
581598
const timeout = setTimeout(() => controller.abort(), 10000);
@@ -585,11 +602,11 @@ export class App {
585602
const text = await resp.text();
586603
satellites = await parseSatelliteDataParallel(text);
587604
await cachePut('tlescope_tle_custom_' + src.id, { ts: Date.now(), data: text, count: satellites.length });
588-
sourcesStore.setLoadState(src.id, { satCount: satellites.length, status: 'loaded' });
605+
sourcesStore.setLoadState(src.id, { satCount: satellites.length, status: 'loaded', epochAge: this.computeEpochAge(satellites) });
589606
} else {
590607
const text = await sourcesStore.getCustomText(src.id);
591608
satellites = await parseSatelliteDataParallel(text);
592-
sourcesStore.setLoadState(src.id, { satCount: satellites.length, status: 'loaded' });
609+
sourcesStore.setLoadState(src.id, { satCount: satellites.length, status: 'loaded', epochAge: this.computeEpochAge(satellites) });
593610
}
594611
this.sourceData.set(src.id, satellites);
595612
} catch (e) {

src/stores/sources.svelte.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,20 @@ export interface TLESourceConfig {
1212
builtin: boolean; // true = CelesTrak, can't delete
1313
}
1414

15+
export interface EpochAgeStats {
16+
avgMs: number; // average epoch age in ms
17+
oldestMs: number; // oldest (max age) epoch in ms
18+
newestMs: number; // newest (min age) epoch in ms
19+
p25Ms: number; // 25th percentile
20+
p50Ms: number; // median
21+
p75Ms: number; // 75th percentile
22+
}
23+
1524
export interface SourceLoadState {
1625
satCount: number;
1726
status: 'idle' | 'loading' | 'loaded' | 'error';
1827
cacheAge?: number;
28+
epochAge?: EpochAgeStats;
1929
error?: string;
2030
}
2131

src/ui/DataSourcesWindow.svelte

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
import Input from './shared/Input.svelte';
77
import { uiStore } from '../stores/ui.svelte';
88
import { sourcesStore, type TLESourceConfig, type SourceType } from '../stores/sources.svelte';
9-
// getCacheAge is now async (IndexedDB) — we use loadState.cacheAge instead
109
import { ICON_DATA_SOURCES, ICON_DOWNLOAD, ICON_EDIT, ICON_CLOSE } from './shared/icons';
10+
import { tooltip } from './shared/tooltip';
1111
import VirtualList from './shared/VirtualList.svelte';
1212
import { textToRecords, ommToJson, type DataFormat } from '../data/omm-formats';
1313
@@ -153,12 +153,29 @@
153153
return `${days}d`;
154154
}
155155
156-
function getCacheInfo(src: { id: string; group?: string; type: string }): string | null {
156+
function epochRow(label: string, ms: number): string {
157+
return ` ${label} <span class="val">${formatAge(ms)}</span>`;
158+
}
159+
160+
function getEpochAgeInfo(src: { id: string }): { label: string; tooltip: string } | null {
157161
const state = sourcesStore.loadStates.get(src.id);
158-
if (state?.status === 'loaded' && state.cacheAge != null) {
159-
return formatAge(state.cacheAge);
162+
if (state?.status !== 'loaded' || !state.epochAge) return null;
163+
const ea = state.epochAge;
164+
const label = formatAge(ea.avgMs);
165+
const lines = [
166+
`<b>Epoch age</b> <span class="dim">(TLE freshness)</span>`,
167+
epochRow('Newest ', ea.newestMs),
168+
epochRow('P25 ', ea.p25Ms),
169+
epochRow('Median ', ea.p50Ms),
170+
epochRow('Average', ea.avgMs),
171+
epochRow('P75 ', ea.p75Ms),
172+
epochRow('Oldest ', ea.oldestMs),
173+
];
174+
let html = lines.join('\n');
175+
if (state.cacheAge != null) {
176+
html += `<div class="sep"></div>Fetched <span class="val">${formatAge(state.cacheAge)}</span> ago`;
160177
}
161-
return null;
178+
return { label, tooltip: html };
162179
}
163180
164181
function openPasteModal() {
@@ -266,7 +283,7 @@
266283
<div class="section-header">CelesTrak{#if filterQuery} <span class="filter-count">({filteredBuiltins.length})</span>{/if}</div>
267284
<div class="source-list">
268285
{#each filteredBuiltins as src}
269-
{@const cached = getCacheInfo(src)}
286+
{@const epochInfo = getEpochAgeInfo(src)}
270287
{@const enabled = sourcesStore.enabledIds.has(src.id)}
271288
<label class="source-row">
272289
<Checkbox size="sm"
@@ -276,8 +293,8 @@
276293
{#if enabled}
277294
<span class="source-count">{getStatus(src.id)}</span>
278295
{/if}
279-
{#if cached}
280-
<span class="cache-age" title="Cached {cached} ago">{cached}</span>
296+
{#if epochInfo}
297+
<span class="epoch-age" use:tooltip={{ html: epochInfo.tooltip }}>{epochInfo.label}</span>
281298
{/if}
282299
{#if hasCachedData(src)}
283300
<button class="action-btn" title="Download" onclick={(e) => { e.preventDefault(); downloadSource(src); }}>{@html ICON_DOWNLOAD}</button>
@@ -293,6 +310,7 @@
293310
{#if customSources.length > 0}
294311
<div class="source-list custom-list">
295312
{#each customSources as src}
313+
{@const epochInfo = getEpochAgeInfo(src)}
296314
<label class="source-row">
297315
<Checkbox size="sm"
298316
checked={sourcesStore.enabledIds.has(src.id)}
@@ -301,6 +319,9 @@
301319
{#if sourcesStore.enabledIds.has(src.id)}
302320
<span class="source-count">{getStatus(src.id)}</span>
303321
{/if}
322+
{#if epochInfo}
323+
<span class="epoch-age" use:tooltip={{ html: epochInfo.tooltip }}>{epochInfo.label}</span>
324+
{/if}
304325
{#if hasCachedData(src)}
305326
<button class="action-btn" title="Download" onclick={(e) => { e.preventDefault(); downloadSource(src); }}>{@html ICON_DOWNLOAD}</button>
306327
<button class="action-btn" title="Edit" onclick={(e) => { e.preventDefault(); openEditModal(src); }}>{@html ICON_EDIT}</button>
@@ -488,10 +509,12 @@
488509
color: var(--text-ghost);
489510
flex-shrink: 0;
490511
}
491-
.cache-age {
512+
.epoch-age {
492513
font-size: 10px;
493514
color: var(--text-faint);
494515
flex-shrink: 0;
516+
white-space: pre-line;
517+
cursor: default;
495518
}
496519
.action-btn {
497520
background: none;

src/ui/shared/tooltip.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/** Svelte action for styled tooltips. Replaces native `title` attributes.
2+
*
3+
* Usage:
4+
* use:tooltip={'Simple text'}
5+
* use:tooltip={{ text: 'Hello', placement: 'top' }}
6+
* use:tooltip={{ html: '<b>Bold</b> text', placement: 'bottom' }}
7+
*/
8+
9+
export type TooltipPlacement = 'top' | 'bottom';
10+
11+
export interface TooltipOptions {
12+
text?: string;
13+
html?: string;
14+
placement?: TooltipPlacement;
15+
}
16+
17+
type TooltipParam = string | TooltipOptions | null | undefined;
18+
19+
interface ParsedTooltip {
20+
content: string;
21+
isHtml: boolean;
22+
placement: TooltipPlacement;
23+
}
24+
25+
const GAP = 6;
26+
const VIEWPORT_PAD = 8;
27+
28+
let activeEl: HTMLElement | null = null;
29+
let tipEl: HTMLDivElement | null = null;
30+
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
31+
32+
function ensureStyle() {
33+
if (document.getElementById('_tooltip-style')) return;
34+
const style = document.createElement('style');
35+
style.id = '_tooltip-style';
36+
style.textContent = `
37+
._tooltip {
38+
position: fixed;
39+
z-index: 99999;
40+
background: var(--tooltip-bg, #111);
41+
border: 1px solid var(--border, #333);
42+
color: var(--text-dim, #aaa);
43+
font-family: 'Overpass Mono', monospace;
44+
font-size: 11px;
45+
line-height: 1.5;
46+
padding: 6px 10px;
47+
border-radius: 3px;
48+
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
49+
pointer-events: none;
50+
white-space: pre;
51+
max-width: min(360px, calc(100vw - ${VIEWPORT_PAD * 2}px));
52+
opacity: 0;
53+
transition: opacity 80ms ease-in;
54+
}
55+
._tooltip.visible { opacity: 1; }
56+
._tooltip b { color: var(--text, #e3e3e3); font-weight: 600; }
57+
._tooltip .dim { color: var(--text-muted, #666); }
58+
._tooltip .val { color: var(--text, #e3e3e3); }
59+
._tooltip .sep {
60+
display: block;
61+
height: 0;
62+
border-top: 1px solid var(--border, #333);
63+
margin: 4px 0 6px;
64+
line-height: 0;
65+
font-size: 0;
66+
}
67+
`;
68+
document.head.appendChild(style);
69+
}
70+
71+
function show(node: HTMLElement, parsed: ParsedTooltip) {
72+
hide();
73+
ensureStyle();
74+
75+
activeEl = node;
76+
const el = document.createElement('div');
77+
el.className = '_tooltip';
78+
if (parsed.isHtml) {
79+
el.innerHTML = parsed.content;
80+
} else {
81+
el.textContent = parsed.content;
82+
}
83+
document.body.appendChild(el);
84+
tipEl = el;
85+
86+
// Position after layout
87+
requestAnimationFrame(() => {
88+
if (tipEl !== el) return;
89+
const rect = node.getBoundingClientRect();
90+
const tipRect = el.getBoundingClientRect();
91+
92+
// Horizontal: center on trigger, clamp to viewport
93+
let left = rect.left + rect.width / 2 - tipRect.width / 2;
94+
left = Math.max(VIEWPORT_PAD, Math.min(left, window.innerWidth - tipRect.width - VIEWPORT_PAD));
95+
96+
// Vertical: prefer requested placement, flip if no room
97+
let top: number;
98+
if (parsed.placement === 'bottom') {
99+
top = rect.bottom + GAP;
100+
if (top + tipRect.height > window.innerHeight - VIEWPORT_PAD) {
101+
top = rect.top - tipRect.height - GAP;
102+
}
103+
} else {
104+
top = rect.top - tipRect.height - GAP;
105+
if (top < VIEWPORT_PAD) {
106+
top = rect.bottom + GAP;
107+
}
108+
}
109+
110+
el.style.left = `${left}px`;
111+
el.style.top = `${top}px`;
112+
el.classList.add('visible');
113+
});
114+
}
115+
116+
function hide() {
117+
if (hideTimeout) { clearTimeout(hideTimeout); hideTimeout = null; }
118+
if (tipEl) { tipEl.remove(); tipEl = null; }
119+
activeEl = null;
120+
}
121+
122+
function parse(param: TooltipParam): ParsedTooltip | null {
123+
if (!param) return null;
124+
if (typeof param === 'string') return { content: param, isHtml: false, placement: 'top' };
125+
const content = param.html ?? param.text;
126+
if (!content) return null;
127+
return { content, isHtml: !!param.html, placement: param.placement ?? 'top' };
128+
}
129+
130+
export function tooltip(node: HTMLElement, param: TooltipParam) {
131+
let opts = parse(param);
132+
133+
// Strip native title to prevent double-tooltip
134+
const origTitle = node.getAttribute('title');
135+
if (origTitle) node.removeAttribute('title');
136+
137+
function onEnter() {
138+
if (!opts) return;
139+
if (hideTimeout) { clearTimeout(hideTimeout); hideTimeout = null; }
140+
show(node, opts);
141+
}
142+
143+
function onLeave() {
144+
if (activeEl !== node) return;
145+
hideTimeout = setTimeout(hide, 60);
146+
}
147+
148+
node.addEventListener('pointerenter', onEnter);
149+
node.addEventListener('pointerleave', onLeave);
150+
node.addEventListener('pointerdown', hide);
151+
152+
return {
153+
update(newParam: TooltipParam) {
154+
opts = parse(newParam);
155+
if (activeEl === node && tipEl && opts) {
156+
if (opts.isHtml) {
157+
tipEl.innerHTML = opts.content;
158+
} else {
159+
tipEl.textContent = opts.content;
160+
}
161+
} else if (activeEl === node && !opts) {
162+
hide();
163+
}
164+
},
165+
destroy() {
166+
node.removeEventListener('pointerenter', onEnter);
167+
node.removeEventListener('pointerleave', onLeave);
168+
node.removeEventListener('pointerdown', hide);
169+
if (activeEl === node) hide();
170+
},
171+
};
172+
}

0 commit comments

Comments
 (0)