orbit-studio / src /modules /util /passTimeline.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
5.23 kB
// Laying out a pass list as a strip: where each pass sits along a horizon, how
// tall it is drawn, and where the hour marks and "now" fall.
//
// Pure and Cesium-free, because it is the part worth testing — the component that
// consumes it does nothing but turn percentages into inline styles.
import { passQuality, type Pass } from "../PassPredictor";
const HOUR_MS = 3600 * 1000;
/**
* A little track before now, so the "now" marker is a line *on* the strip rather
* than on its edge. A pass that has just ended stays visible too.
*/
const LEAD_IN_MS = 30 * 60 * 1000;
/**
* How many passes the strip tries to cover, and the window it may not leave.
*
* A fixed span was measured empty. The ISS clumps into a morning and an evening
* group. At 20:30 UTC only two of its twenty-three passes fell inside twelve hours,
* while the table below listed all twenty-three. Sizing the span to the passes
* keeps the strip populated for a clumper and a sun-synchronous regular alike.
*/
const TARGET_BLOCKS = 6;
const MIN_SPAN_MS = 6 * HOUR_MS;
const MAX_SPAN_MS = 48 * HOUR_MS;
/** Bands a pass is drawn in. Three, because three is what a legend can explain. */
export type PassBand = "high" | "mid" | "low";
/**
* Thresholds on `passQuality`. 0.5 and 0.222 are 45° and 20° of elevation, the
* points either side of "worth going outside for". In swath mode they are the same
* fractions of the way from the footprint edge to its centre.
*/
export function passBand(quality: number): PassBand {
if (quality >= 0.5) {
return "high";
}
return quality >= 0.222 ? "mid" : "low";
}
export interface TimelineBlock {
key: string;
startMs: number;
/** Percentages across the strip, ready for `left`/`width`/`height`. */
leftPct: number;
widthPct: number;
heightPct: number;
band: PassBand;
/** Happening now, which the strip draws differently from one merely upcoming. */
live: boolean;
past: boolean;
}
export interface TimelineTick {
label: string;
pct: number;
}
export interface TimelineLayout {
blocks: TimelineBlock[];
ticks: TimelineTick[];
/** Where "now" falls, which is `LEAD_IN_MS` in rather than at the left edge. */
nowPct: number;
/** How far the strip reaches, for the caption: "7 h", "1.5 d". */
horizonLabel: string;
/** Passes that start beyond the strip, so their absence can be accounted for. */
beyond: number;
}
/**
* Lay out the passes that fall inside an automatically sized horizon.
*
* `passes` is the same list the table shows, in the same order. Blocks come back
* keyed by start time. A caller can then match a block to a row without either side
* assuming the two arrays stay index-aligned.
*/
export function passTimelineLayout(passes: readonly Pass[], nowMs: number): TimelineLayout {
const horizonMs = horizonFor(passes, nowMs);
const spanStart = nowMs - LEAD_IN_MS;
const spanEnd = nowMs + horizonMs;
const spanMs = LEAD_IN_MS + horizonMs;
const pct = (epochMs: number): number => ((epochMs - spanStart) / spanMs) * 100;
const blocks = passes
.filter((pass) => pass.end >= spanStart && pass.start <= spanEnd)
.map((pass) => {
const left = pct(Math.max(spanStart, pass.start));
return {
key: String(pass.start),
startMs: pass.start,
leftPct: left,
// Floored, so a nine-minute pass on a two-day strip is still a hittable target.
widthPct: Math.max(1.2, pct(Math.min(spanEnd, pass.end)) - left),
// Never zero-height: a grazing pass still happened.
heightPct: 25 + passQuality(pass) * 75,
band: passBand(passQuality(pass)),
live: pass.start <= nowMs && pass.end >= nowMs,
past: pass.end < nowMs,
};
});
return {
blocks,
ticks: ticksFor(horizonMs, nowMs, pct),
nowPct: pct(nowMs),
horizonLabel: horizonLabel(horizonMs),
beyond: passes.filter((pass) => pass.start > spanEnd).length,
};
}
/** Enough span to hold `TARGET_BLOCKS` passes, clamped so neither extreme is absurd. */
function horizonFor(passes: readonly Pass[], nowMs: number): number {
const upcoming = passes.filter((pass) => pass.end >= nowMs);
const last = upcoming[Math.min(TARGET_BLOCKS, upcoming.length) - 1];
if (!last) {
return MIN_SPAN_MS;
}
// A tenth of headroom past the last block, so it is not flush with the edge.
return Math.min(MAX_SPAN_MS, Math.max(MIN_SPAN_MS, (last.end - nowMs) * 1.1));
}
/** Roughly four marks, on a step a human reads in hours. */
function ticksFor(horizonMs: number, nowMs: number, pct: (epochMs: number) => number): TimelineTick[] {
const hours = horizonMs / HOUR_MS;
const step = [1, 2, 3, 6, 12, 24].find((candidate) => hours / candidate <= 5) ?? 24;
const ticks: TimelineTick[] = [];
for (let hour = step; hour < hours; hour += step) {
const at = pct(nowMs + hour * HOUR_MS);
// Past 90% the label runs off the right edge of the strip.
if (at > 90) {
break;
}
ticks.push({ label: `+${hour}h`, pct: at });
}
return ticks;
}
function horizonLabel(horizonMs: number): string {
const hours = horizonMs / HOUR_MS;
return hours >= 24 ? `${(hours / 24).toFixed(hours % 24 === 0 ? 0 : 1)} d` : `${Math.round(hours)} h`;
}