From 26697b164e8ead6d69ffce9069331018271e5874 Mon Sep 17 00:00:00 2001 From: Marco Supino Date: Sun, 7 Jun 2026 06:30:47 +0300 Subject: [PATCH] Use route templates for leg waypoint suggestions --- docs/app/core.js | 2 + docs/app/draw.js | 55 +++++++++++ docs/app/interact.js | 107 +++++++++++++++++++++ docs/app/ui.js | 5 +- tests/route-templates.spec.js | 171 ++++++++++++++++++++++++++++++++++ 5 files changed, 338 insertions(+), 2 deletions(-) diff --git a/docs/app/core.js b/docs/app/core.js index 5586e0f5..d14a456c 100644 --- a/docs/app/core.js +++ b/docs/app/core.js @@ -662,6 +662,8 @@ var legAltitudeMap = null; // null = not loaded yet (or last fetch failed — var legAltitudePointIds = null; // Set of endpoint ids from the same file. var legAltitudeDataset = null; // Raw validated dataset for Charts copy/view. var legAltitudeDirectionPool = null; // Directed altitude entries, one per allowed direction. +var routeTemplates = null; // null = not loaded yet; [] or populated from route-templates.json. +var templateLegSuggestions = []; // Canvas hit boxes for route-template waypoint chips. var showDrift = true; // 10-degree drift reference lines var showWpNames = true; // draw waypoint names (off = empty circle) var wpNameAngle = 0; // waypoint-name rotation: 0 / 90 / 180 / 270 deg diff --git a/docs/app/draw.js b/docs/app/draw.js index 2fb8dc60..f71f33f5 100644 --- a/docs/app/draw.js +++ b/docs/app/draw.js @@ -1325,6 +1325,7 @@ window.normalizeCommChangeSuppressions = normalizeCommChangeSuppressions; function drawLegs() { const zoomScale = legZoomScale(); + templateLegSuggestions = []; // Pre-compute cumulative outbound times (walk legs in reverse so each // entry is "total return time from the last waypoint through leg i"). @@ -1443,10 +1444,64 @@ function drawLegs() { cumOutArr[i], tune('inkColor'), 'rgba(255,204,214,0.80)', zoomScale); } } + drawTemplateLegSuggestions(i, mid.x, mid.y, dx, dy, nx, ny, zoomScale); if (showMidLeg) drawDistanceBadge(mid.x, mid.y, dist); } } +function drawTemplateLegSuggestions(legIndex, midX, midY, dx, dy, nx, ny, zoomScale) { + if (typeof routeTemplateSuggestionsForLeg !== 'function') return; + const suggestions = routeTemplateSuggestionsForLeg(legIndex); + if (!suggestions.length) return; + + const fontPx = Math.max(11, Math.round(12 * zoomScale)); + const padX = 7; + const h = fontPx + 9; + const gap = 4; + const labels = suggestions.map(s => String(s.label || s.name || '').trim()).filter(Boolean); + if (!labels.length) return; + + octx.save(); + octx.font = `bold ${fontPx}px sans-serif`; + const widths = labels.map(label => Math.ceil(octx.measureText(label).width) + padX * 2); + const totalW = widths.reduce((sum, w) => sum + w, 0) + gap * (widths.length - 1); + const perp = legDefaultLabelPerp(1) + h + 10; + let x = midX - totalW / 2; + const y = midY + ny * perp - h / 2; + const alongAdjust = (dy < -0.2 ? 1 : 0) * (h + 2); + x += dx * alongAdjust; + const baseY = y + dy * alongAdjust; + + octx.textAlign = 'center'; + octx.textBaseline = 'middle'; + for (let j = 0; j < labels.length; j++) { + const w = widths[j]; + const bx = x; + const by = baseY; + octx.fillStyle = 'rgba(42, 36, 29, 0.92)'; + octx.strokeStyle = 'rgba(255, 214, 93, 0.92)'; + octx.lineWidth = 1.5; + octx.beginPath(); + if (typeof octx.roundRect === 'function') octx.roundRect(bx, by, w, h, 5); + else octx.rect(bx, by, w, h); + octx.fill(); + octx.stroke(); + octx.fillStyle = '#fff2a8'; + octx.fillText(labels[j], bx + w / 2, by + h / 2 + 0.5); + templateLegSuggestions.push({ + legIndex, + name: suggestions[j].name, + templateId: suggestions[j].templateId || '', + x: bx, + y: by, + w, + h, + }); + x += w + gap; + } + octx.restore(); +} + // Drift reference lines, one from each end, defaulting to half the leg length. function drawDriftLines(sa, sb) { const a = driftAngleRad(); diff --git a/docs/app/interact.js b/docs/app/interact.js index c185db31..b88ed4a1 100644 --- a/docs/app/interact.js +++ b/docs/app/interact.js @@ -674,6 +674,106 @@ function splitLegAt(legIndex, latlng) { } window.splitLegAt = splitLegAt; +function routeTemplateWaypointKey(wp) { + const raw = String(wp && wp.name || '').trim(); + if (!raw) return ''; + if (typeof findNavWpToken === 'function') { + const ref = findNavWpToken(raw); + if (ref && ref.name) return String(ref.name).toUpperCase(); + } + const canonical = typeof canonicalNavWaypointName === 'function' + ? canonicalNavWaypointName(raw) : raw; + return String(canonical || raw).trim().toUpperCase(); +} + +function routeTemplateSuggestionsForLeg(legIndex) { + if (!showNavWP || !Array.isArray(routeTemplates) || !routeTemplates.length) return []; + const i = Number(legIndex); + if (!Number.isInteger(i) || i < 0 || i >= state.legs.length) return []; + const from = routeTemplateWaypointKey(state.waypoints[i]); + const to = routeTemplateWaypointKey(state.waypoints[i + 1]); + if (!from || !to || from === to) return []; + + const alreadyInRoute = new Set(state.waypoints.map(routeTemplateWaypointKey).filter(Boolean)); + const out = []; + const seen = new Set(); + const paths = []; + for (const template of routeTemplates) { + const points = Array.isArray(template && template.waypoints) + ? template.waypoints.map(p => String(p || '').trim().toUpperCase()).filter(Boolean) + : []; + for (let start = 0; start < points.length - 2; start++) { + if (points[start] !== from) continue; + for (let end = start + 2; end < points.length; end++) { + if (points[end] === to) { + paths.push({ + templateId: template.id || '', + missing: points.slice(start + 1, end), + }); + break; + } + } + } + } + paths.sort((a, b) => a.missing.length - b.missing.length); + for (const path of paths) { + for (const name of path.missing) { + if (alreadyInRoute.has(name) || seen.has(name)) continue; + seen.add(name); + out.push({ + legIndex: i, + name, + templateId: path.templateId, + label: typeof navName === 'function' ? (navName(name) || name) : name, + }); + if (out.length >= 6) return out; + } + } + return out; +} +window.routeTemplateSuggestionsForLeg = routeTemplateSuggestionsForLeg; + +function insertRouteTemplateSuggestion(legIndex, name) { + const i = Number(legIndex); + if (!Number.isInteger(i) || i < 0 || i >= state.legs.length) return false; + if (!state.waypoints[i] || !state.waypoints[i + 1]) return false; + if (typeof findNavWpToken !== 'function') return false; + const point = findNavWpToken(name); + if (!point || !Number.isFinite(point.lat) || !Number.isFinite(point.lng)) return false; + + const source = state.legs[i]; + const inserted = { lat: r5(point.lat), lng: r5(point.lng), name: point.name }; + const first = splitLegCopy(source); + const second = splitLegCopy(source); + if (source && source._legAltitudeAuto) { + first._legAltitudeAuto = 1; + second._legAltitudeAuto = 1; + } + state.waypoints.splice(i + 1, 0, inserted); + state.legs.splice(i, 1, first, second); + if (state.legs.length !== Math.max(0, state.waypoints.length - 1)) syncLegs(); + if (source && source._legAltitudeAuto && + typeof applyLegAltitudesToRoute === 'function') { + applyLegAltitudesToRoute(); + } + if (typeof seedCommChangeNotes === 'function') seedCommChangeNotes(); + state.selected = { type: 'wp', index: i + 1 }; + draw(); + showInspector(); + return true; +} +window.insertRouteTemplateSuggestion = insertRouteTemplateSuggestion; + +function hitRouteTemplateSuggestion(px, py) { + if (!Array.isArray(templateLegSuggestions)) return null; + for (let i = templateLegSuggestions.length - 1; i >= 0; i--) { + const s = templateLegSuggestions[i]; + if (px >= s.x && px <= s.x + s.w && py >= s.y && py <= s.y + s.h) return s; + } + return null; +} +window.hitRouteTemplateSuggestion = hitRouteTemplateSuggestion; + function deleteSelectedWpOrNote() { if (state.selected.type === 'wp') { deleteWaypoint(state.selected.index); @@ -1771,6 +1871,12 @@ map.on('mousedown', e => { showInspector(); draw(); return; } + const suggestion = hitRouteTemplateSuggestion(p.x, p.y); + if (suggestion) { + downHit = true; + insertRouteTemplateSuggestion(suggestion.legIndex, suggestion.name); + return; + } const cum = hitCumLabel(p.x, p.y); if (cum) { downHit = true; @@ -2102,6 +2208,7 @@ mapEl.addEventListener('dblclick', e => { const p = { x: e.clientX - rect.left, y: e.clientY - rect.top }; if (hitNote(p.x, p.y) >= 0) return; if (hitWaypointCandidates(p.x, p.y).length) return; + if (hitRouteTemplateSuggestion(p.x, p.y)) return; if (hitCumLabel(p.x, p.y) || hitCumLabelRet(p.x, p.y) || hitLegLabel(p.x, p.y)) return; if (hitOverlayMarkerCandidates(p.x, p.y).length) return; const leg = hitLeg(p.x, p.y); diff --git a/docs/app/ui.js b/docs/app/ui.js index fdb5a836..4954eb2c 100644 --- a/docs/app/ui.js +++ b/docs/app/ui.js @@ -518,8 +518,6 @@ async function buildRouteFromQuery(raw) { return true; } -let routeTemplates = null; - function routeTemplateLabel(template) { const lang = (window.__navLang || document.documentElement.lang || '').toLowerCase(); if (lang.slice(0, 2) === 'he' && template.he) return template.he; @@ -2030,6 +2028,9 @@ loadLegAltitudes().then(() => { if (state.selected) showInspector(); } }); +loadRouteTemplates().then(() => { + draw(); +}); // Comm-change dataset (issue #399): parallel fetch so the rings appear // on first paint and the inspector badge is available immediately for // a selection restored from sessionStorage. Rings draw independently of diff --git a/tests/route-templates.spec.js b/tests/route-templates.spec.js index 6320bbce..604b0061 100644 --- a/tests/route-templates.spec.js +++ b/tests/route-templates.spec.js @@ -128,6 +128,177 @@ test.describe('route templates', () => { ])); }); + test('template suggestions expose missing waypoints for a direct template leg', async ({ page }) => { + await boot(page); + const out = await page.evaluate(async () => { + await Promise.all([loadNavWaypoints(), loadAirfields(), loadRouteTemplates()]); + const a = findNavWpToken('LLHZ'); + const b = findNavWpToken('LLHA'); + state.waypoints = [ + { lat: a.lat, lng: a.lng, name: a.name }, + { lat: b.lat, lng: b.lng, name: b.name }, + ]; + syncLegs(); + window.showNavWP = true; + return routeTemplateSuggestionsForLeg(0).map(s => s.name); + }); + expect(out).toEqual(['BAZRA', 'DEROR', 'SHARO', 'HADRA', 'FRDIS', 'BOREN']); + }); + + test('template suggestions follow the Show nav waypoints toggle', async ({ page }) => { + await boot(page); + const out = await page.evaluate(async () => { + await Promise.all([loadNavWaypoints(), loadAirfields(), loadRouteTemplates()]); + const a = findNavWpToken('LLHZ'); + const b = findNavWpToken('LLHA'); + state.waypoints = [ + { lat: a.lat, lng: a.lng, name: a.name }, + { lat: b.lat, lng: b.lng, name: b.name }, + ]; + syncLegs(); + window.showNavWP = false; + const hidden = routeTemplateSuggestionsForLeg(0).length; + window.showNavWP = true; + const shown = routeTemplateSuggestionsForLeg(0).length; + return { hidden, shown }; + }); + expect(out.hidden).toBe(0); + expect(out.shown).toBeGreaterThan(0); + }); + + test('inserting a template suggestion preserves manual leg fields and resets markers', async ({ page }) => { + await boot(page); + const out = await page.evaluate(async () => { + await Promise.all([loadNavWaypoints(), loadAirfields(), loadRouteTemplates()]); + const a = findNavWpToken('LLHZ'); + const b = findNavWpToken('DEROR'); + state.waypoints = [ + { lat: a.lat, lng: a.lng, name: a.name }, + { lat: b.lat, lng: b.lng, name: b.name }, + ]; + syncLegs(); + Object.assign(state.legs[0], { + inboundAltitude: 1234, + outboundAltitude: 2345, + flightSpeed: 111, + outboundSpeed: 122, + vorRef: 'NAT', + _legAltitudeOutboundBlocked: 1, + _legAltitudeOneWay: 1, + inLabel: { a: 9, p: 3, _m: 1 }, + outLabel: { a: -7, p: -2, _m: 1 }, + cumLabel: { a: 5, p: 1, _m: 1 }, + cumLabelRet: { a: -5, p: -1, _m: 1 }, + }); + delete state.legs[0]._legAltitudeAuto; + const ok = insertRouteTemplateSuggestion(0, 'BAZRA'); + const fields = leg => ({ + inboundAltitude: leg.inboundAltitude, + outboundAltitude: leg.outboundAltitude, + flightSpeed: leg.flightSpeed, + outboundSpeed: leg.outboundSpeed, + vorRef: leg.vorRef, + outboundBlocked: Boolean(leg._legAltitudeOutboundBlocked), + oneWay: Boolean(leg._legAltitudeOneWay), + auto: Boolean(leg._legAltitudeAuto), + inLabel: leg.inLabel, + outLabel: leg.outLabel, + cumLabel: leg.cumLabel, + cumLabelRet: leg.cumLabelRet, + }); + return { + ok, + waypoints: state.waypoints.map(w => w.name), + legs: state.legs.length, + selected: state.selected, + first: fields(state.legs[0]), + second: fields(state.legs[1]), + }; + }); + expect(out.ok).toBe(true); + expect(out.waypoints).toEqual(['LLHZ', 'BAZRA', 'DEROR']); + expect(out.legs).toBe(2); + expect(out.selected).toEqual({ type: 'wp', index: 1 }); + for (const leg of [out.first, out.second]) { + expect(leg).toMatchObject({ + inboundAltitude: 1234, + outboundAltitude: 2345, + flightSpeed: 111, + outboundSpeed: 122, + vorRef: 'NAT', + outboundBlocked: true, + oneWay: true, + auto: false, + }); + expect(leg.inLabel).toEqual({ a: 0, _default: 1, _m: 1 }); + expect(leg.outLabel).toEqual({ a: 0, _default: 1, _m: 1 }); + expect(leg.cumLabel).toEqual({ a: 0, _default: 1, _m: 1 }); + expect(leg.cumLabelRet).toEqual({ a: 0, _default: 1, _m: 1 }); + } + }); + + test('template suggestion insert keeps automatic altitude lookup active', async ({ page }) => { + await boot(page); + const out = await page.evaluate(async () => { + await Promise.all([ + loadNavWaypoints(), + loadAirfields(), + loadLegAltitudes(), + loadRouteTemplates(), + ]); + const a = findNavWpToken('LLHZ'); + const b = findNavWpToken('DEROR'); + state.waypoints = [ + { lat: a.lat, lng: a.lng, name: a.name }, + { lat: b.lat, lng: b.lng, name: b.name }, + ]; + syncLegs(); + state.legs[0]._legAltitudeAuto = 1; + insertRouteTemplateSuggestion(0, 'BAZRA'); + return { + waypoints: state.waypoints.map(w => w.name), + auto: state.legs.map(l => Boolean(l._legAltitudeAuto)), + keys: state.legs.map(l => l._legAltitudeKey || ''), + alts: state.legs.map(l => [l.inboundAltitude, l.outboundAltitude]), + }; + }); + expect(out.waypoints).toEqual(['LLHZ', 'BAZRA', 'DEROR']); + expect(out.auto).toEqual([true, true]); + expect(out.keys).toEqual(['BAZRA-LLHZ', 'BAZRA-DEROR']); + expect(out.alts).toEqual([[800, 1200], [800, 2000]]); + }); + + test('clicking a drawn template suggestion inserts that waypoint', async ({ page }) => { + await boot(page); + const chip = await page.evaluate(async () => { + await Promise.all([loadNavWaypoints(), loadAirfields(), loadRouteTemplates()]); + const a = findNavWpToken('LLHZ'); + const b = findNavWpToken('DEROR'); + state.waypoints = [ + { lat: a.lat, lng: a.lng, name: a.name }, + { lat: b.lat, lng: b.lng, name: b.name }, + ]; + syncLegs(); + window.showNavWP = true; + map.setView([(a.lat + b.lat) / 2, (a.lng + b.lng) / 2], 10, { animate: false }); + draw(); + const suggestion = templateLegSuggestions.find(s => s.name === 'BAZRA'); + if (!suggestion) throw new Error('BAZRA chip not drawn'); + const rect = map.getContainer().getBoundingClientRect(); + return { + x: rect.left + suggestion.x + suggestion.w / 2, + y: rect.top + suggestion.y + suggestion.h / 2, + }; + }); + await page.mouse.click(chip.x, chip.y); + const out = await page.evaluate(() => ({ + waypoints: state.waypoints.map(w => w.name), + selected: state.selected, + })); + expect(out.waypoints).toEqual(['LLHZ', 'BAZRA', 'DEROR']); + expect(out.selected).toEqual({ type: 'wp', index: 1 }); + }); + for (const templateCase of [ { id: 'llhz-llib-north',