Files
website/src/lib/radio/index.ts
2026-10-26 17:24:03 +00:00

337 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Radio tracker (/trackers/radio, owner-only): pure helpers shared by the page and
// scripts/radio.ts (ISS refresh + reminders). No server imports here: the script runs inside the
// app container, which ships src/lib/radio (see Dockerfile).
export const TZ = 'Europe/Amsterdam';
export const BANDS = ['marine', 'air', 'ham', 'pmr', 'other'] as const;
export type Band = (typeof BANDS)[number];
export const BAND_LABEL: Record<Band, string> = {
marine: 'marine',
air: 'airband',
ham: 'ham',
pmr: 'PMR',
other: 'other'
};
/** Gradient per band: colourful icons on a black-and-white page. */
export const BAND_COLOR: Record<Band, [string, string]> = {
marine: ['#38bdf8', '#0369a1'],
air: ['#a78bfa', '#5b21b6'],
ham: ['#fb7185', '#be123c'],
pmr: ['#fbbf24', '#b45309'],
other: ['#a1a1aa', '#3f3f46']
};
export const MODES = ['FM', 'NFM', 'AM', 'USB', 'LSB', 'CW', 'DATA'] as const;
export type Mode = (typeof MODES)[number];
/** Modes the UBC125XLT can store (AUTO aside); others export as AUTO. */
export const SCANNER_MODES = new Set<string>(['FM', 'NFM', 'AM']);
/** Uniden UBC125XLT layout: 10 banks of 50 channels. */
export const BANKS = 10;
export const PER_BANK = 50;
export const CHANNELS = BANKS * PER_BANK;
export const bankOf = (channel: number) => Math.floor((channel - 1) / PER_BANK) + 1;
export const bankRange = (bank: number) => [(bank - 1) * PER_BANK + 1, bank * PER_BANK] as const;
/** The UBC125XLT shows 16-character alpha tags. */
export const ALPHA_TAG_MAX = 16;
/** Minimum max-elevation for an ISS pass to count (shown highlighted, reminded). */
export const ISS_MIN_EL = 10;
export const REMIND_MINUTES = 10;
export const ISS_DOWNLINK_HZ = 145_800_000;
/** No reminders are sent when the send time falls in this Amsterdam window (RADIO_QUIET_HOURS overrides). */
export const QUIET_HOURS = '00:00-08:00';
export type Quiet = { from: number; to: number; label: string } | null;
/** "00:00-08:00" -> minutes of day; "off" / "" -> null (no quiet hours). */
export function parseQuiet(spec: string | null | undefined): Quiet {
const m = /^\s*(\d{1,2}):(\d{2})\s*[-–]\s*(\d{1,2}):(\d{2})\s*$/.exec(spec ?? '');
if (!m) return null;
const from = Number(m[1]) * 60 + Number(m[2]);
const to = Number(m[3]) * 60 + Number(m[4]);
if (from > 1440 || to > 1440 || from === to) return null;
const pad = (n: number) =>
`${String(Math.floor(n / 60)).padStart(2, '0')}:${String(n % 60).padStart(2, '0')}`;
return { from, to, label: `${pad(from)}–${pad(to)}` };
}
export const DAY_SHORT = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;
/** 145450000 -> "145.4500" (MHz, 4 decimals like the scanner); below 30 MHz -> "3785 kHz". */
export function formatFreq(hz: number | null | undefined, unit = true): string {
if (hz == null) return '';
if (hz < 30_000_000) {
const khz = hz / 1000;
return `${Number.isInteger(khz) ? khz : khz.toFixed(1)}${unit ? ' kHz' : ''}`;
}
return `${(hz / 1e6).toFixed(4)}${unit ? ' MHz' : ''}`;
}
/** MHz with 4 decimals, no unit (CSV/programming software). */
export const mhz = (hz: number) => (hz / 1e6).toFixed(4);
/**
* Parse a typed frequency: "145.45", "145.450 MHz", "3785 kHz", "3785k", "145450000".
* Bare numbers < 1000 are MHz, 1000..999999 kHz, larger Hz. Returns Hz or null.
*/
export function parseFreq(input: string | null | undefined): number | null {
if (!input) return null;
const s = input.trim().toLowerCase().replace(',', '.').replace(/\s+/g, '');
const m = s.match(/^(\d+(?:\.\d+)?)(mhz|m|khz|k|hz)?$/);
if (!m) return null;
const n = Number(m[1]);
if (!Number.isFinite(n) || n <= 0) return null;
const u = m[2];
let hz: number;
if (u === 'mhz' || u === 'm') hz = n * 1e6;
else if (u === 'khz' || u === 'k') hz = n * 1e3;
else if (u === 'hz') hz = n;
else if (n < 1000) hz = n * 1e6;
else if (n < 1_000_000) hz = n * 1e3;
else hz = n;
hz = Math.round(hz);
return hz >= 1000 && hz <= 10_000_000_000 ? hz : null;
}
const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] as const;
export const compass = (az: number) => COMPASS[Math.round((((az % 360) + 360) % 360) / 45) % 8];
// ---- time zones -------------------------------------------------------------------------------
const partsFmt = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
weekday: 'short'
});
const WD: Record<string, number> = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
/** Wall-clock parts of an instant in Europe/Amsterdam (isoDay 1 = Monday). */
export function zoned(at: Date) {
const p = Object.fromEntries(partsFmt.formatToParts(at).map((x) => [x.type, x.value]));
return {
y: Number(p.year),
m: Number(p.month),
d: Number(p.day),
h: Number(p.hour),
min: Number(p.minute),
s: Number(p.second),
isoDay: WD[p.weekday as string] ?? 1
};
}
/** The instant at which Europe/Amsterdam's wall clock reads y-m-d h:min. */
export function fromZoned(y: number, m: number, d: number, h: number, min: number): Date {
const guess = Date.UTC(y, m - 1, d, h, min);
// Offset at the guess, then correct once more for DST edges.
let t = guess;
for (let i = 0; i < 2; i++) {
const z = zoned(new Date(t));
const asUtc = Date.UTC(z.y, z.m - 1, z.d, z.h, z.min, z.s);
t = guess - (asUtc - t);
}
return new Date(t);
}
/** "11:00" / "11:00:00" -> [11, 0]. */
export const hm = (t: string) => {
const [h, m] = t.split(':').map(Number);
return [h || 0, m || 0] as const;
};
export type NetSchedule = { id: number; days: number[]; times: string[]; active?: boolean };
/** Occurrences of a weekly schedule (Amsterdam wall time) between from and to. */
export function occurrences(net: NetSchedule, from: Date, to: Date): Date[] {
if (net.active === false || !net.days.length || !net.times.length) return [];
const out: Date[] = [];
const start = zoned(new Date(from.getTime() - 86_400_000));
// Walk calendar days in Amsterdam (via noon UTC so DST never skips a day).
let cursor = Date.UTC(start.y, start.m - 1, start.d, 12);
while (cursor <= to.getTime() + 86_400_000) {
const dt = new Date(cursor);
const y = dt.getUTCFullYear();
const m = dt.getUTCMonth() + 1;
const d = dt.getUTCDate();
const isoDay = ((dt.getUTCDay() + 6) % 7) + 1;
if (net.days.includes(isoDay)) {
for (const t of net.times) {
const [h, mi] = hm(t);
const at = fromZoned(y, m, d, h, mi);
if (at > from && at <= to) out.push(at);
}
}
cursor += 86_400_000;
}
return out.sort((a, b) => a.getTime() - b.getTime());
}
export const nextOccurrence = (net: NetSchedule, now: Date) =>
occurrences(net, now, new Date(now.getTime() + 8 * 86_400_000))[0] ?? null;
const timeFmt = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
});
const dayFmt = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ,
weekday: 'short',
day: 'numeric',
month: 'short'
});
export const fmtTime = (at: Date | string | number) => timeFmt.format(new Date(at));
export const fmtDay = (at: Date | string | number) => dayFmt.format(new Date(at));
/** Amsterdam calendar date "2026-10-26" (for grouping). */
export const dayKey = (at: Date | string | number) => {
const z = zoned(new Date(at));
return `${z.y}-${String(z.m).padStart(2, '0')}-${String(z.d).padStart(2, '0')}`;
};
/** "today", "tomorrow" or "Wed 28 Oct". */
export function relDay(at: Date | string | number, now: Date | number = Date.now()) {
const k = dayKey(at);
if (k === dayKey(now)) return 'today';
if (k === dayKey(new Date(now).getTime() + 86_400_000)) return 'tomorrow';
return fmtDay(at);
}
/** "in 5 min", "in 2 h 10 min", "now". */
export function until(at: Date | string | number, now: Date | number = Date.now()) {
const mins = Math.round((new Date(at).getTime() - new Date(now).getTime()) / 60_000);
if (mins <= 0) return 'now';
if (mins < 60) return `in ${mins} min`;
const h = Math.floor(mins / 60);
if (h < 24) return `in ${h} h${mins % 60 ? ` ${mins % 60} min` : ''}`;
const d = Math.round(h / 24);
return `in ${d} day${d === 1 ? '' : 's'}`;
}
/** Days as text: [1,2,3,4,5] -> "weekdays", all 7 -> "daily", else "mon, wed". */
export function daysLabel(days: number[]) {
const s = [...new Set(days)].sort();
if (s.length === 7) return 'daily';
if (s.join() === '1,2,3,4,5') return 'weekdays';
if (s.join() === '6,7') return 'weekends';
return s.map((d) => DAY_SHORT[d - 1]).join(', ');
}
// ---- scanner CSV ------------------------------------------------------------------------------
export type ChannelRow = {
channel: number;
alphaTag: string | null;
freqHz: number | null;
mode: string | null;
delay: boolean;
lockout: boolean;
priority: boolean;
};
const csvCell = (v: string | number) => {
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
/**
* All 500 channels as CSV in the column order of Uniden's BC125AT/UBC125XLT programming software
* (BC125AT_SS: Channel, Name, Frequency, Modulation, CTCSS/DCS, Delay, Lockout, Priority) plus a
* leading Bank column. Empty channels are blank rows so the file always maps 1:1 to memories.
*/
export function scannerCsv(rows: ChannelRow[]): string {
const by = new Map(rows.map((r) => [r.channel, r]));
const lines = [
[
'Bank',
'Channel',
'Name',
'Frequency',
'Modulation',
'CTCSS/DCS',
'Delay',
'Lockout',
'Priority'
]
.map(csvCell)
.join(',')
];
for (let ch = 1; ch <= CHANNELS; ch++) {
const r = by.get(ch);
const has = r && r.freqHz != null;
lines.push(
[
bankOf(ch),
ch,
has ? (r.alphaTag ?? '').slice(0, ALPHA_TAG_MAX) : '',
has ? mhz(r.freqHz!) : '',
has ? (SCANNER_MODES.has(r.mode ?? '') ? r.mode! : 'AUTO') : 'AUTO',
'OFF',
has ? (r.delay ? 2 : 0) : 2,
has ? (r.lockout ? 'ON' : 'OFF') : 'OFF',
has ? (r.priority ? 'ON' : 'OFF') : 'OFF'
]
.map(csvCell)
.join(',')
);
}
return lines.join('\r\n') + '\r\n';
}
/** Short scanner tag from a station name (abbreviations, then cut to 16 characters). */
export function alphaTag(name: string, marineCh?: string | null): string {
const ABBR: [RegExp, string][] = [
[/\bSchiphol\b/gi, 'SPL'],
[/\bRotterdam\b/gi, 'RTM'],
[/\bScheveningen\b/gi, 'SCHEV'],
[/\bMaastricht\b/gi, 'MUAC'],
[/\bBrussel\b/gi, 'BRU'],
[/\bLondon\b/gi, 'LON'],
[/\bNieuw Milligen\b/gi, 'MIL'],
[/\bDordrecht\b/gi, 'DORD'],
[/\bKustwacht\b/gi, 'KWC'],
[/\bTower\b|\bToren\b/gi, 'TWR'],
[/\bApproach\b/gi, 'APP'],
[/\bArrival\b/gi, 'ARR'],
[/\bDeparture\b/gi, 'DEP'],
[/\bRadar\b/gi, 'RDR'],
[/\bOperations\b/gi, 'OPS'],
[/\bEurocontrol\b/gi, ''],
[/\bArea Control\b/gi, 'AC'],
[/\bSector\b/gi, ''],
[/\bBaan\b/gi, ''],
[/\bZweefvliegtuigen\b/gi, 'Gliders'],
[/\bInternational(e)?\b|\bInternationaal\b/gi, 'Intl'],
[/\bEmergency Frequency\b/gi, 'Emergency'],
[/\bReddings\s*Brigade\b/gi, 'RB'],
[/\bSluizen\b/gi, ''],
[/\bVolkeraksluizen\b/gi, 'Volkerak'],
[/\bSleep Dienst\b/gi, 'Tug'],
[/\bScheepvaartbedrijven\b|\bScheepvaart bedrijven\b/gi, 'Shipping'],
[/\bRijkswaterstaat\b/gi, 'RWS'],
[/\bLandelijk Scheepsportofoon\b/gi, 'Ship UHF'],
[/\bKanaal\b/gi, 'ch'],
[/\bSchip\b/gi, 'S'],
[/\bWal\b/gi, 'W']
];
let s = name;
for (const [re, to] of ABBR) s = s.replace(re, to);
s = s
.replace(/\([^)]*\)/g, '')
.replace(/\s+/g, ' ')
.trim();
if (marineCh) s = `${marineCh} ${s}`;
return s.slice(0, ALPHA_TAG_MAX).trim();
}
/** Whether an instant falls in the quiet window (Amsterdam wall clock; the window may wrap midnight). */
export function inQuiet(at: Date, quiet: Quiet): boolean {
if (!quiet) return false;
const z = zoned(at);
const t = z.h * 60 + z.min;
return quiet.from < quiet.to ? t >= quiet.from && t < quiet.to : t >= quiet.from || t < quiet.to;
}