Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions src/components/import/sources/AirportSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { formatPlural } from '../../../utils/formatPlural';
import { useImportStores } from '../../../hooks/useImportStores';
import { getNextChannelNumber, selectionCardClass } from '../../../utils/importHelpers';
import { generateAirportChannels } from '../../../services/airportChannels';
import { generateAirportChannels, COMMON_AIRCRAFT_FREQUENCIES } from '../../../services/airportChannels';
import { getAirportFrequenciesWithTypes, type AirportData } from '../../../data/airportsData';
import { SelectAllButtons } from '../SelectAllButtons';
import { Button } from '../../ui/Button';
Expand All @@ -26,8 +26,22 @@ export const AirportSource: React.FC<AirportSourceProps> = ({

const [selectedAirports, setSelectedAirports] = useState<Set<number>>(new Set());
const [airportZoneGrouping, setAirportZoneGrouping] = useState<'individual' | 'single'>('individual');
const [includeCommonFrequencies, setIncludeCommonFrequencies] = useState(false);
const [selectedCommonFreqs, setSelectedCommonFreqs] = useState<Set<number>>(
new Set(COMMON_AIRCRAFT_FREQUENCIES.map((_, i) => i))
);
const [isAddingAirports, setIsAddingAirports] = useState(false);

const handleToggleCommonFreq = (index: number) => {
const newSelected = new Set(selectedCommonFreqs);
if (newSelected.has(index)) {
newSelected.delete(index);
} else {
newSelected.add(index);
}
setSelectedCommonFreqs(newSelected);
};

const handleToggleAirport = (index: number) => {
const newSelected = new Set(selectedAirports);
if (newSelected.has(index)) {
Expand Down Expand Up @@ -67,11 +81,17 @@ export const AirportSource: React.FC<AirportSourceProps> = ({

const nextChannelNumber = getNextChannelNumber(channels);

// Build the selected subset of common aircraft frequencies (if enabled)
const commonFreqs = includeCommonFrequencies
? COMMON_AIRCRAFT_FREQUENCIES.filter((_, i) => selectedCommonFreqs.has(i))
: [];

// Generate channels and zones for selected airports
const result = generateAirportChannels(
nextChannelNumber,
selectedAirportList, // Pass selected airports
airportZoneGrouping === 'single' // Group all in one zone if selected
airportZoneGrouping === 'single', // Group all in one zone if selected
commonFreqs // Common aircraft frequencies to also add
);

if (result.channels.length === 0) {
Expand Down Expand Up @@ -188,6 +208,45 @@ export const AirportSource: React.FC<AirportSourceProps> = ({
<span className="text-cool-gray">Single zone (all airports together)</span>
</label>
</div>
<label className="flex items-center mt-3">
<input
type="checkbox"
checked={includeCommonFrequencies}
onChange={(e) => setIncludeCommonFrequencies(e.target.checked)}
className="mr-2"
/>
<span className="text-cool-gray">Add common aircraft frequencies (Guard, UNICOM, air-to-air, etc.)</span>
</label>
{includeCommonFrequencies && (
<div className="mt-2 ml-6">
<div className="flex justify-between items-center mb-2">
<span className="text-xs text-cool-gray opacity-75">
{airportZoneGrouping === 'single'
? 'Added to the Airports zone'
: 'Added as a separate "Aircraft" zone'}
</span>
<SelectAllButtons
onSelectAll={() =>
setSelectedCommonFreqs(new Set(COMMON_AIRCRAFT_FREQUENCIES.map((_, i) => i)))
}
onDeselectAll={() => setSelectedCommonFreqs(new Set())}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-56 overflow-y-auto">
{COMMON_AIRCRAFT_FREQUENCIES.map((freq, index) => (
<label key={index} className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={selectedCommonFreqs.has(index)}
onChange={() => handleToggleCommonFreq(index)}
/>
<span className="text-neon-cyan min-w-[55px]">{freq.freq.toFixed(3)}</span>
<span className="text-cool-gray">{freq.name}</span>
</label>
))}
</div>
</div>
)}
</div>
<Button
onClick={handleAddAirportChannels}
Expand Down
76 changes: 75 additions & 1 deletion src/services/airportChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,35 @@ function removeDistance(airport: AirportData & { distance?: number }): AirportDa
return airportData;
}

/**
* Common / itinerant aircraft VHF frequencies (nationwide, not airport-specific).
* Source: RadioReference "Aircraft" wiki – Common Civilian Frequencies.
* These are receive-only airband channels useful alongside airport channels.
*/
export interface CommonAircraftFrequency {
freq: number;
name: string;
}

export const COMMON_AIRCRAFT_FREQUENCIES: CommonAircraftFrequency[] = [
{ freq: 121.5, name: 'Guard 121.5' },
{ freq: 122.7, name: 'Unicom 122.7' },
{ freq: 122.725, name: 'Unicom 122.725' },
{ freq: 122.75, name: 'Air-Air 122.75' },
{ freq: 122.8, name: 'Unicom 122.8' },
{ freq: 122.85, name: 'Multicom 122.85' },
{ freq: 122.9, name: 'Multicom 122.9' },
{ freq: 122.925, name: 'Multicom 122.92' },
{ freq: 122.95, name: 'Unicom 122.95' },
{ freq: 122.975, name: 'Unicom 122.975' },
{ freq: 123.0, name: 'Unicom 123.0' },
{ freq: 123.025, name: 'Helo A-A 123.02' },
{ freq: 123.05, name: 'Unicom 123.05' },
{ freq: 123.075, name: 'Unicom 123.075' },
{ freq: 123.1, name: 'SAR 123.1' },
{ freq: 123.45, name: 'Air-Air 123.45' },
];

/**
* Get airport code (ICAO) from airport data
*/
Expand All @@ -28,11 +57,14 @@ function getAirportCode(airport: AirportData): string {
* @param startChannelNumber - Starting channel number
* @param selectedAirports - Array of airports to generate channels for (required)
* @param singleZone - If true, creates one zone with all airports. If false, creates one zone per airport.
* @param commonFrequencies - Common/itinerant aircraft frequencies to also add. In single-zone mode they are
* appended to the "Airports" zone; in individual-zone mode they are placed in a separate "Aircraft" zone.
*/
export function generateAirportChannels(
startChannelNumber: number = 1,
selectedAirports: AirportData[], // Required: airports to generate channels for
singleZone: boolean = false // If true, group all airports in one zone
singleZone: boolean = false, // If true, group all airports in one zone
commonFrequencies: CommonAircraftFrequency[] = [] // Common aircraft frequencies to also add
): {
channels: Channel[];
zones: Zone[];
Expand Down Expand Up @@ -158,12 +190,54 @@ export function generateAirportChannels(

// Create single zone with all airports (if single zone mode)
if (singleZone && allZoneChannels.length > 0) {
// Optionally append common / itinerant aircraft frequencies to the zone
for (const common of commonFrequencies) {
const channel = createDefaultChannel({
number: channelNumber++,
name: common.name,
rxFrequency: common.freq,
txFrequency: NO_TX_FREQUENCY, // Receive-only: TX stored as 0xFF on radio
forbidTx: true,
mode: 'Analog',
bandwidth: '25kHz', // Aviation uses 25kHz spacing
power: 'High',
scanAdd: true,
});
channels.push(channel);
allZoneChannels.push(channel.number);
}

zones.push({
id: generateZoneId(),
name: 'Airports',
channels: allZoneChannels,
});
}

// In individual-zone mode, add common frequencies as their own "Aircraft" zone
if (!singleZone && commonFrequencies.length > 0) {
const commonZoneChannels: number[] = [];
for (const common of commonFrequencies) {
const channel = createDefaultChannel({
number: channelNumber++,
name: common.name,
rxFrequency: common.freq,
txFrequency: NO_TX_FREQUENCY, // Receive-only: TX stored as 0xFF on radio
forbidTx: true,
mode: 'Analog',
bandwidth: '25kHz', // Aviation uses 25kHz spacing
power: 'High',
scanAdd: true,
});
channels.push(channel);
commonZoneChannels.push(channel.number);
}
zones.push({
id: generateZoneId(),
name: 'Aircraft',
channels: commonZoneChannels,
});
}

return {
channels,
Expand Down
52 changes: 51 additions & 1 deletion tests/unit/airportChannels.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { generateAirportChannels } from '../../src/services/airportChannels';
import { generateAirportChannels, COMMON_AIRCRAFT_FREQUENCIES } from '../../src/services/airportChannels';
import type { AirportData } from '../../src/data/airportsData';

function airport(code: string, lat: number, lon: number, frequencies: number | [number, string][]): AirportData {
Expand Down Expand Up @@ -71,3 +71,53 @@ describe('generateAirportChannels', () => {
expect(result.summary.zonesCreated).toBe(result.zones.length);
});
});

describe('generateAirportChannels with common frequencies', () => {
it('does not add common frequencies by default', () => {
const base = generateAirportChannels(1, [yvr, klax], true);
expect(base.channels.length).toBe(4); // 2 freqs x 2 airports
});

it('appends common frequencies to the Airports zone in single zone mode', () => {
const result = generateAirportChannels(1, [yvr, klax], true, COMMON_AIRCRAFT_FREQUENCIES);
expect(result.channels.length).toBe(4 + COMMON_AIRCRAFT_FREQUENCIES.length);
expect(result.zones.length).toBe(1);
expect(result.zones[0].name).toBe('Airports');
expect(result.zones[0].channels.length).toBe(4 + COMMON_AIRCRAFT_FREQUENCIES.length);
});

it('adds common frequencies as a separate Aircraft zone in individual mode', () => {
const result = generateAirportChannels(1, [yvr, klax], false, COMMON_AIRCRAFT_FREQUENCIES);
expect(result.channels.length).toBe(4 + COMMON_AIRCRAFT_FREQUENCIES.length);
expect(result.zones.length).toBe(3); // 2 airport zones + 1 Aircraft zone
const aircraftZone = result.zones.find(z => z.name === 'Aircraft');
expect(aircraftZone).toBeDefined();
expect(aircraftZone!.channels.length).toBe(COMMON_AIRCRAFT_FREQUENCIES.length);
});

it('only adds the selected subset of common frequencies', () => {
const subset = COMMON_AIRCRAFT_FREQUENCIES.slice(0, 3);
const result = generateAirportChannels(1, [yvr], true, subset);
expect(result.channels.length).toBe(2 + 3);
});

it('adds no common frequencies when the list is empty', () => {
const result = generateAirportChannels(1, [yvr, klax], false, []);
expect(result.channels.length).toBe(4);
expect(result.zones.every(z => z.name !== 'Aircraft')).toBe(true);
});

it('common frequency channels are receive-only', () => {
const result = generateAirportChannels(1, [yvr], true, COMMON_AIRCRAFT_FREQUENCIES);
const commonChannels = result.channels.slice(-COMMON_AIRCRAFT_FREQUENCIES.length);
commonChannels.forEach(ch => {
expect(ch.forbidTx).toBe(true);
});
});

it('common frequency channel names are 16 chars or fewer', () => {
COMMON_AIRCRAFT_FREQUENCIES.forEach(f => {
expect(f.name.length).toBeLessThanOrEqual(16);
});
});
});
Loading