Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .ai/navaid-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,9 @@ commit on `main`, `dev`, or an unrelated feature branch by mistake.
plan as plain white-page tables rather than a modal screenshot. The CSV
button beside Print downloads the currently displayed forward/return plan
tables as UTF-8 BOM-prefixed `flight-plan-*.csv`, excluding modal controls
and delete buttons.
and delete buttons. The Nav log button opens a print-ready kneeboard
document; its comm-change radio-frequency list is sorted by route waypoint
order, not by note insertion order.
- **Show Nav Waypoints** (default **on**): `nav-waypoints.json` is
fetched once at boot; renders 173 white-fill / black-stroke 3.5 px
dots; the 5-letter ID label appears at zoom ≥ 10. Captured in PNG
Expand Down
21 changes: 17 additions & 4 deletions docs/app/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -1567,16 +1567,29 @@ function commChangeReferencePoint(name) {
: null;
return refAf || null;
}
function knownRoutePointKey(name) {
const key = canonicalNavWaypointName(name);
if (!key) return '';
if (Array.isArray(navWP) && navWP.some(w => w && canonicalNavWaypointName(w.name) === key)) {
return key;
}
if (Array.isArray(airfields) && airfields.some(a => a && canonicalNavWaypointName(a.name) === key)) {
return key;
}
return '';
}
function commChangeWaypointInRange(wp, name) {
if (!wp || typeof map === 'undefined' || !map) return false;
const key = canonicalNavWaypointName(name);
if (!key) return false;
const wpKey = canonicalNavWaypointName(wp.name);
if (wpKey && wpKey !== key && knownRoutePointKey(wpKey)) return false;
const ref = commChangeReferencePoint(key);
// When there is no reference position, fall back to name equality.
if (!ref) return canonicalNavWaypointName(wp.name) === key;
// Position is authoritative — a renamed waypoint still triggers if it
// sits on the comm-change reference point (name check removed so renaming
// does not silently disable the frequency-change indicator).
if (!ref) return wpKey === key;
// Position is authoritative only for custom / renamed points — a known
// route waypoint with a different code must not snap to a nearby comm point
// at low zoom (e.g. HTZUK must not activate KNTRY).
const a = map.latLngToContainerPoint([wp.lat, wp.lng]);
const b = map.latLngToContainerPoint([ref.lat, ref.lng]);
return Math.hypot(a.x - b.x, a.y - b.y) <= tune('commChangeSnapPx');
Expand Down
22 changes: 19 additions & 3 deletions docs/app/io.js
Original file line number Diff line number Diff line change
Expand Up @@ -2373,9 +2373,25 @@ function showFlightPlan() {
// Wrap LTR content (codes, frequencies) in an isolate so it doesn't
// reorder against surrounding Hebrew in the RTL nav log.
const ltr = s => '<span dir="ltr" style="unicode-bidi:isolate">' + esc(s) + '</span>';
// Frequency list from comm-change callout notes on the route. Use the
// localized call-sign name (Hebrew in he mode), not the raw catalog id.
const freqs = (state.notes || []).filter(n => n && n.cc).map(n => {
// Frequency list from comm-change callout notes on the route. Use route
// waypoint order, not note insertion order, so the kneeboard reads along
// the flight path. The label uses the localized call-sign name (Hebrew in
// he mode), not the raw catalog id.
const routeNoteOrder = item => {
const wpi = item && Number.isInteger(item.wpi) && item.wpi >= 0
? item.wpi : Number.MAX_SAFE_INTEGER;
return wpi;
};
const freqs = (state.notes || [])
.map((n, idx) => ({
n,
idx,
wpi: (n && n.cc && typeof commCalloutWaypointIndex === 'function')
? commCalloutWaypointIndex(n) : -1,
}))
.filter(item => item.n && item.n.cc)
.sort((a, b) => routeNoteOrder(a) - routeNoteOrder(b) || a.idx - b.idx)
.map(({ n }) => {
const wp = (typeof navName === 'function' ? navName(n.cc) : n.cc) || n.cc;
const name = typeof commNoteName === 'function' ? commNoteName(n) : (n.freqName || '');
const fq = typeof commNoteFreq === 'function' ? commNoteFreq(n) : (n.freq || '');
Expand Down
84 changes: 75 additions & 9 deletions tests/nav-log.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,17 @@
// window with a header, the per-leg table, and a frequency list.
const { test, expect } = require('@playwright/test');

test('Nav log opens a printable document with header, table and freqs', async ({ page, context }) => {
async function boot(page) {
await page.addInitScript(() => {
try { for (const s of ['build', 'view', 'display', 'charts', 'export', 'print']) {
localStorage.setItem('navaid.sec.' + s, '1');
} } catch (e) {}
});
await page.goto('?lang=en');
await page.waitForFunction(() => typeof state !== 'undefined' && typeof showFlightPlan === 'function');
await page.evaluate(() => {
state.waypoints = [{ lat: 32.0, lng: 34.8, name: 'LLSD' },
{ lat: 32.4, lng: 35.0, name: 'LLHA' }];
state.notes = [{ lat: 32.2, lng: 34.9, text: '', shape: 'rect', color: '#ffd84a',
cc: 'LLSD', freqName: 'TLV', freq: '118.40' }];
syncLegs(); draw(); showFlightPlan();
});
// Stub print so headless doesn't block, then click Nav log and grab the popup.
}

async function openNavLog(page, context) {
const [popup] = await Promise.all([
context.waitForEvent('page'),
page.evaluate(() => {
Expand All @@ -27,10 +22,81 @@ test('Nav log opens a printable document with header, table and freqs', async ({
}),
]);
await popup.waitForLoadState('domcontentloaded');
return popup;
}

test('Nav log opens a printable document with header, table and freqs', async ({ page, context }) => {
await boot(page);
await page.evaluate(() => {
state.waypoints = [{ lat: 32.0, lng: 34.8, name: 'LLSD' },
{ lat: 32.4, lng: 35.0, name: 'LLHA' }];
state.notes = [{ lat: 32.2, lng: 34.9, text: '', shape: 'rect', color: '#ffd84a',
cc: 'LLSD', freqName: 'TLV', freq: '118.40' }];
syncLegs(); draw(); showFlightPlan();
});
// Stub print so headless doesn't block, then click Nav log and grab the popup.
const popup = await openNavLog(page, context);
const txt = await popup.evaluate(() => document.body.innerText);
expect(txt).toContain('LLSD');
expect(txt).toContain('LLHA');
expect(txt).toContain('Frequencies');
expect(txt).toContain('118.40');
expect(await popup.locator('table.flight-table').count()).toBeGreaterThan(0);
});

test('Nav log radio frequencies follow route waypoint order', async ({ page, context }) => {
await boot(page);
await page.evaluate(() => {
state.waypoints = [
{ lat: 32.0, lng: 34.8, name: 'START' },
{ lat: 32.2, lng: 34.9, name: 'MID' },
{ lat: 32.4, lng: 35.0, name: 'END' },
];
state.notes = [
{ lat: 32.4, lng: 35.0, text: '', shape: 'rect', color: '#ffd84a',
cc: 'END', freqName: 'END_RADIO', freq: '133.30' },
{ lat: 32.0, lng: 34.8, text: '', shape: 'rect', color: '#ffd84a',
cc: 'START', freqName: 'START_RADIO', freq: '118.10' },
{ lat: 32.2, lng: 34.9, text: '', shape: 'rect', color: '#ffd84a',
cc: 'MID', freqName: 'MID_RADIO', freq: '122.20' },
];
syncLegs(); draw(); showFlightPlan();
});

const popup = await openNavLog(page, context);
const freqs = await popup.evaluate(() => {
const h2 = [...document.querySelectorAll('h2')]
.find(el => (el.textContent || '').trim() === 'Frequencies');
return h2 && h2.nextElementSibling
? [...h2.nextElementSibling.querySelectorAll('li')].map(li => li.textContent)
: [];
});
expect(freqs).toEqual([
expect.stringContaining('START_RADIO'),
expect.stringContaining('MID_RADIO'),
expect.stringContaining('END_RADIO'),
]);
});

test('Herzliya to Beer Sheva nav log does not include the reverse Country Club frequency', async ({ page, context }) => {
await boot(page);
await page.evaluate(async () => {
await Promise.all([loadNavWaypoints(), loadAirfields(), loadCommChange(), loadRouteTemplates()]);
const template = routeTemplates.find(t => t.id === 'llhz-llbs-south');
if (!template) throw new Error('missing llhz-llbs-south template');
await applyRouteTemplate(template, template.defaultSpeed, null);
showFlightPlan();
});

const popup = await openNavLog(page, context);
const freqs = await popup.evaluate(() => {
const h2 = [...document.querySelectorAll('h2')]
.find(el => (el.textContent || '').trim() === 'Frequencies');
return h2 && h2.nextElementSibling
? [...h2.nextElementSibling.querySelectorAll('li')].map(li => li.textContent)
: [];
});
expect(freqs.some(line => /Country|KNTRY|HERZLIYA|125\.60/.test(line || ''))).toBe(false);
expect(freqs.at(-1)).toMatch(/Teyman/i);
expect(freqs.at(-1)).toEqual(expect.stringContaining('122.50'));
});
Loading