All checks were successful
Deploy production / deploy (push) Successful in 1m26s
210 lines
4.7 KiB
Svelte
210 lines
4.7 KiB
Svelte
<script lang="ts">
|
|
// ISS passes over Gouda: start / max / end (Amsterdam time), max elevation, direction.
|
|
import Eye from 'lucide-svelte/icons/eye';
|
|
import {
|
|
ISS_DOWNLINK_HZ,
|
|
ISS_MIN_EL,
|
|
compass,
|
|
dayKey,
|
|
fmtTime,
|
|
formatFreq,
|
|
relDay,
|
|
until
|
|
} from './index.js';
|
|
import type { IssPass } from '#lib/server/radio.js';
|
|
|
|
let {
|
|
passes,
|
|
tle = null,
|
|
now,
|
|
limit = 0,
|
|
compact = false
|
|
}: {
|
|
passes: IssPass[];
|
|
tle?: { name: string; epoch: string | null; fetchedAt: string | null } | null;
|
|
now: number;
|
|
limit?: number;
|
|
compact?: boolean;
|
|
} = $props();
|
|
|
|
let showLow = $state(false);
|
|
const list = $derived.by(() => {
|
|
const l = passes.filter(
|
|
(p) => new Date(p.endAt).getTime() >= now && (showLow || p.maxEl >= ISS_MIN_EL)
|
|
);
|
|
return limit ? l.slice(0, limit) : l;
|
|
});
|
|
const days = $derived.by(() => {
|
|
const out: { key: string; label: string; passes: IssPass[] }[] = [];
|
|
for (const p of list) {
|
|
const k = dayKey(p.startAt);
|
|
if (out.at(-1)?.key !== k) out.push({ key: k, label: relDay(p.startAt, now), passes: [] });
|
|
out.at(-1)!.passes.push(p);
|
|
}
|
|
return out;
|
|
});
|
|
const mins = (p: IssPass) =>
|
|
Math.round((new Date(p.endAt).getTime() - new Date(p.startAt).getTime()) / 60_000);
|
|
const fmtAgo = (iso: string) =>
|
|
new Date(iso).toLocaleString('en-GB', {
|
|
timeZone: 'Europe/Amsterdam',
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short'
|
|
});
|
|
</script>
|
|
|
|
{#if !compact}
|
|
<div class="bar">
|
|
<p class="meta">
|
|
Gouda (52.011 N, 4.711 E) · listen on <strong>{formatFreq(ISS_DOWNLINK_HZ)} NFM</strong>
|
|
(ARISS voice / SSTV) ·
|
|
<Eye size={12} /> = sunlit in a dark sky, visible to the eye
|
|
</p>
|
|
<label class="low"
|
|
><input type="checkbox" bind:checked={showLow} />passes below {ISS_MIN_EL}°</label
|
|
>
|
|
</div>
|
|
{/if}
|
|
<div class="iss">
|
|
{#if days.length}
|
|
{#each days as d (d.key)}
|
|
<h3 class="day">{d.label}</h3>
|
|
<ul class="passes">
|
|
{#each d.passes as p (p.startAt)}
|
|
{@const live = new Date(p.startAt).getTime() <= now}
|
|
<li class={{ low: p.maxEl < ISS_MIN_EL, live }}>
|
|
<span class="times">
|
|
<b>{fmtTime(p.startAt)}</b><span class="arrow">→</span>{fmtTime(p.maxAt)}<span
|
|
class="arrow">→</span
|
|
>{fmtTime(p.endAt)}
|
|
</span>
|
|
<span class="el" title="max elevation">
|
|
<span class="elbar"><i style:width="{Math.min(100, (p.maxEl / 90) * 100)}%"></i></span
|
|
>{Math.round(p.maxEl)}°
|
|
</span>
|
|
<span class="dir">{compass(p.startAz)} → {compass(p.maxAz)} → {compass(p.endAz)}</span>
|
|
<span class="dur">{mins(p)} min</span>
|
|
<span class="vis"
|
|
>{#if p.sunlit}<Eye size={14} aria-label="visible to the eye" />{/if}</span
|
|
>
|
|
<span class="rel">{live ? 'overhead now' : until(p.startAt, now)}</span>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/each}
|
|
{:else}
|
|
<p class="empty">No passes computed yet (the daily refresh fills this).</p>
|
|
{/if}
|
|
</div>
|
|
{#if !compact && tle}
|
|
<p class="meta foot">
|
|
{tle.name} TLE from Celestrak, epoch {tle.epoch ? fmtAgo(tle.epoch) : '?'}, fetched {tle.fetchedAt
|
|
? fmtAgo(tle.fetchedAt)
|
|
: '?'} (refreshed daily).
|
|
</p>
|
|
{/if}
|
|
|
|
<style>
|
|
.low {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
font-size: 0.8rem;
|
|
color: var(--site-muted);
|
|
}
|
|
.day {
|
|
font-size: 0.72rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--site-muted);
|
|
margin: 0.8rem 0 0.25rem;
|
|
}
|
|
.passes {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
li {
|
|
display: grid;
|
|
grid-template-columns: 11.5rem 5.5rem 1fr 3.5rem 1.2rem 6.5rem;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.4rem 0.3rem;
|
|
border-radius: 0.45rem;
|
|
font-size: 0.84rem;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
li + li {
|
|
border-top: 1px solid var(--site-border);
|
|
}
|
|
li.low {
|
|
opacity: 0.5;
|
|
}
|
|
li.live {
|
|
background: color-mix(in srgb, #2dd4bf 14%, transparent);
|
|
}
|
|
.arrow {
|
|
margin: 0 0.25rem;
|
|
color: var(--site-muted);
|
|
}
|
|
.el {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
}
|
|
.elbar {
|
|
width: 2.4rem;
|
|
height: 5px;
|
|
border-radius: 3px;
|
|
background: var(--site-border);
|
|
overflow: hidden;
|
|
}
|
|
.elbar i {
|
|
display: block;
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #2dd4bf, #0f766e);
|
|
}
|
|
.dir,
|
|
.dur,
|
|
.rel {
|
|
color: var(--site-muted);
|
|
font-size: 0.78rem;
|
|
}
|
|
.rel {
|
|
text-align: right;
|
|
}
|
|
.vis {
|
|
color: #fbbf24;
|
|
display: inline-flex;
|
|
}
|
|
.foot {
|
|
margin-top: 0.8rem;
|
|
}
|
|
.iss {
|
|
container-type: inline-size;
|
|
}
|
|
@container (max-width: 40rem) {
|
|
li {
|
|
grid-template-columns: minmax(0, 1fr) auto 1.2rem;
|
|
row-gap: 0.1rem;
|
|
}
|
|
.times {
|
|
grid-area: 1 / 1;
|
|
}
|
|
.el {
|
|
grid-area: 1 / 2;
|
|
}
|
|
.vis {
|
|
grid-area: 1 / 3;
|
|
}
|
|
.dir {
|
|
grid-area: 2 / 1;
|
|
}
|
|
.dur {
|
|
display: none;
|
|
}
|
|
.rel {
|
|
grid-area: 2 / 2 / 3 / 4;
|
|
}
|
|
}
|
|
</style>
|