From 724baea54ff4cfa6b02ac5e5c6167fe3089667e4 Mon Sep 17 00:00:00 2001 From: Marco Supino Date: Tue, 16 Jun 2026 18:55:57 +0300 Subject: [PATCH] Sort nav log frequencies by route order --- .ai/navaid-dev.md | 4 ++- docs/app/draw.js | 21 ++++++++--- docs/app/io.js | 22 ++++++++++-- tests/nav-log.spec.js | 84 ++++++++++++++++++++++++++++++++++++++----- 4 files changed, 114 insertions(+), 17 deletions(-) diff --git a/.ai/navaid-dev.md b/.ai/navaid-dev.md index b7e71590..ba0a5d1e 100644 --- a/.ai/navaid-dev.md +++ b/.ai/navaid-dev.md @@ -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 diff --git a/docs/app/draw.js b/docs/app/draw.js index d95726bc..069d1a79 100644 --- a/docs/app/draw.js +++ b/docs/app/draw.js @@ -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'); diff --git a/docs/app/io.js b/docs/app/io.js index e4023854..e76873a9 100644 --- a/docs/app/io.js +++ b/docs/app/io.js @@ -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 => '' + esc(s) + ''; - // 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 || ''); diff --git a/tests/nav-log.spec.js b/tests/nav-log.spec.js index 6b712ddc..c7740298 100644 --- a/tests/nav-log.spec.js +++ b/tests/nav-log.spec.js @@ -2,7 +2,7 @@ // 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'); @@ -10,14 +10,9 @@ test('Nav log opens a printable document with header, table and freqs', async ({ }); 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(() => { @@ -27,6 +22,20 @@ 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'); @@ -34,3 +43,60 @@ test('Nav log opens a printable document with header, table and freqs', async ({ 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')); +});