From 683207dc6f903b25be2e4191ede146e2c448ac42 Mon Sep 17 00:00:00 2001 From: Marco Supino Date: Tue, 26 May 2026 12:39:44 +0300 Subject: [PATCH 01/11] Flight plan pin-to-map + PNG export - Pin button on flight plan modal toggles pinned state (persisted in navaid.planPin). - Resize handle (bottom-right corner) for drag-resizing the flight plan box; size saved to navaid.planW/H. - drawFlightPlanTable() renders the flight-plan table on a canvas context. - exportPNG() includes the pinned flight plan at its screen position/size when pinned. - CSS: .modal-pin, .resize-handle styles. - EN/HE i18n: fpPin, fpUnpin strings. - Tests: pin toggle, persist on close/reopen, resize handle visibility, export with pinned plan. --- docs/core.js | 2 + docs/he/strings.js | 2 + docs/io.js | 206 +++++++++++++++++++++++++++++++ docs/style.css | 39 ++++++ tests/export-png-options.spec.js | 23 ++++ tests/flight-plan.spec.js | 41 ++++++ 6 files changed, 313 insertions(+) diff --git a/docs/core.js b/docs/core.js index 6dca486e..3790a16a 100644 --- a/docs/core.js +++ b/docs/core.js @@ -88,6 +88,8 @@ window.S = Object.assign({ fpTotal: 'Total', fpClose: 'Close', fpPrint: 'Print', + fpPin: 'Pin', + fpUnpin: 'Unpin', fpFuel: 'Fuel', tbAircraft: 'Aircraft', tbGph: 'Gallons per Hour', diff --git a/docs/he/strings.js b/docs/he/strings.js index ae26a289..a234ad93 100644 --- a/docs/he/strings.js +++ b/docs/he/strings.js @@ -50,6 +50,8 @@ window.S = { fpReturn: 'מסלול חזרה', fpClose: 'סגור', fpPrint: 'הדפס', + fpPin: 'נעץ', + fpUnpin: 'שחרר', fpFuel: 'דלק', tbAircraft: 'כלי טיס', tbGph: 'גלונים לשעה', diff --git a/docs/io.js b/docs/io.js index ea1a085c..60d84d37 100644 --- a/docs/io.js +++ b/docs/io.js @@ -837,6 +837,86 @@ function showFlightPlan() { box.appendChild(btns); addModalCloseX(box, closeFlightPlan); + // Pin button — toggles flight plan visibility on PNG export. + const pinBtn = document.createElement('button'); + pinBtn.className = 'modal-pin'; + pinBtn.type = 'button'; + pinBtn.textContent = '\uD83D\uDCCC'; + const PLANPIN_KEY = 'navaid.planPin'; + let planPinned = localStorage.getItem(PLANPIN_KEY) === '1'; + pinBtn.title = planPinned ? S.fpUnpin : S.fpPin; + if (planPinned) pinBtn.classList.add('active'); + pinBtn.onclick = function () { + planPinned = !planPinned; + pinBtn.classList.toggle('active', planPinned); + pinBtn.title = planPinned ? S.fpUnpin : S.fpPin; + try { localStorage.setItem(PLANPIN_KEY, planPinned ? '1' : '0'); } catch (e) {} + }; + box.appendChild(pinBtn); + + // Resize handle — bottom-right corner drag to resize. + var rHandle = document.createElement('div'); + rHandle.className = 'resize-handle'; + box.appendChild(rHandle); + (function () { + var rx = 0, ry = 0, rw = 0, rh = 0, rDrag = false; + function rStart(cx, cy) { + var r = box.getBoundingClientRect(); + rx = cx; ry = cy; + rw = r.width; rh = r.height; + rDrag = true; + } + function rMove(cx, cy) { + if (!rDrag) return; + box.style.width = Math.max(300, rw + cx - rx) + 'px'; + box.style.height = Math.max(200, rh + cy - ry) + 'px'; + } + function rEnd() { + if (!rDrag) return; + rDrag = false; + try { localStorage.setItem('navaid.planW', box.offsetWidth); localStorage.setItem('navaid.planH', box.offsetHeight); } catch (e) {} + } + function rTouchStart(e) { + if (e.touches.length !== 1) return; + e.preventDefault(); + rStart(e.touches[0].clientX, e.touches[0].clientY); + } + function rTouchMove(e) { + if (!rDrag || e.touches.length !== 1) return; + e.preventDefault(); + rMove(e.touches[0].clientX, e.touches[0].clientY); + } + rHandle.addEventListener('mousedown', function (e) { + e.preventDefault(); + rStart(e.clientX, e.clientY); + var onMove = function (ev) { rMove(ev.clientX, ev.clientY); }; + var onUp = function () { rEnd(); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }); + rHandle.addEventListener('touchstart', rTouchStart, { passive: false }); + window.addEventListener('touchmove', rTouchMove, { passive: false }); + window.addEventListener('touchend', rEnd); + window.addEventListener('touchcancel', rEnd); + // Extend flightPlanCleanup with resize-teardown. + var prevCleanup = flightPlanCleanup; + flightPlanCleanup = function () { + if (typeof prevCleanup === 'function') prevCleanup(); + rHandle.removeEventListener('touchstart', rTouchStart, { passive: false }); + window.removeEventListener('touchmove', rTouchMove, { passive: false }); + window.removeEventListener('touchend', rEnd); + window.removeEventListener('touchcancel', rEnd); + }; + })(); + + // Restore saved size. + try { + const sw = localStorage.getItem('navaid.planW'); + const sh = localStorage.getItem('navaid.planH'); + if (sw) box.style.width = sw + 'px'; + if (sh) box.style.height = sh + 'px'; + } catch (e) {} + back.appendChild(box); // Close via the Close button or Escape (#86). document.body.appendChild(back); @@ -1160,6 +1240,116 @@ function showExportModal() { document.addEventListener('keydown', onEsc); } +// Draw the flight-plan table on an export canvas at the given position and +// size. Called from exportPNG() when the flight plan is pinned to the map. +// `ctx` is the output-canvas 2d context at identity transform; x/y/w/h are +// output-canvas pixels. +function drawFlightPlanTable(ctx, x, y, w, h) { + const legs = state.legs || []; + const wpts = state.waypoints || []; + if (!legs.length || wpts.length < 2) return; + const ac = aircraft; + const taxiFuel = ac && ac.taxiGal && isAirport(wpts[0]) ? ac.taxiGal : 0; + // Compute rows. + const rows = []; + let totDist = 0, totTime = 0, totFuel = 0; + for (let i = 0; i < legs.length; i++) { + const A = wpts[i], B = wpts[i + 1]; + if (!A || !B) continue; + const { dist, brg } = geo(A, B); + const hdg = toMagnetic(brg); + const dur = legs[i].flightSpeed > 0 ? dist / legs[i].flightSpeed : 0; + let fuel = ac ? dur * ac.gph : 0; + if (i === 0 && taxiFuel) fuel += taxiFuel; + totDist += dist; + totTime += dur; + totFuel += fuel; + const fLabel = ac ? fuel.toFixed(1) + (i === 0 && taxiFuel ? ' *' : '') : '--'; + rows.push({ num: i + 1, from: navName((A.name || '').trim()) || S.wpPrefix + (i + 1), + to: navName((B.name || '').trim()) || S.wpPrefix + (i + 2), + hdg: pad3(hdg) + '\u00B0M', dist: dist.toFixed(1), + speed: String(legs[i].flightSpeed), alt: String(legs[i].inboundAltitude), + time: dur > 0 ? toHMS(dur) : '--', fuel: fLabel }); + } + if (!rows.length) return; + const headers = S.fpHeaders; + const numCols = headers.length; + // Column width fractions (sum to 1). + const colFrac = [0.04, 0.17, 0.17, 0.11, 0.10, 0.10, 0.10, 0.10, 0.11]; + const numRows = rows.length + 2; // header + data + total + const rowH = h / numRows; + const fontSize = Math.min(rowH * 0.55, 13); + const padX = 5; + // Text alignment per column. + const aligns = ['center', 'left', 'left', 'center', 'right', 'right', 'right', 'center', 'right']; + ctx.save(); + ctx.font = fontSize + 'px sans-serif'; + // Background. + ctx.fillStyle = 'rgba(42,38,38,0.92)'; + ctx.fillRect(x, y, w, h); + // Border. + ctx.strokeStyle = '#4a4646'; + ctx.lineWidth = 1; + ctx.strokeRect(x, y, w, h); + // Helper: draw one cell. + function cell(row, col, text, bold, bg) { + const cx = x + colFrac.slice(0, col).reduce(function (a, b) { return a + b; }, 0) * w; + const cy = y + row * rowH; + const cw = colFrac[col] * w; + if (bg) { ctx.fillStyle = bg; ctx.fillRect(cx, cy, cw, rowH); } + // Right/bottom grid lines (avoid overdraw on last column/row). + ctx.strokeStyle = '#4a4646'; + ctx.lineWidth = 0.5; + if (col < numCols - 1) { ctx.beginPath(); ctx.moveTo(cx + cw, cy); ctx.lineTo(cx + cw, cy + rowH); ctx.stroke(); } + if (row < numRows - 1) { ctx.beginPath(); ctx.moveTo(cx, cy + rowH); ctx.lineTo(cx + cw, cy + rowH); ctx.stroke(); } + // Text. + ctx.fillStyle = bold ? '#e8e8e8' : '#d0d0d0'; + ctx.font = (bold ? 'bold ' : '') + fontSize + 'px sans-serif'; + ctx.textBaseline = 'middle'; + var a = aligns[col]; + ctx.textAlign = a; + var tx = a === 'right' ? cx + cw - padX : a === 'center' ? cx + cw / 2 : cx + padX; + ctx.fillText(text, tx, cy + rowH / 2); + } + // Header. + for (var c = 0; c < numCols; c++) cell(0, c, headers[c], true, '#3a3636'); + // Data rows. + for (var r = 0; r < rows.length; r++) { + var rd = rows[r]; + var vals = [rd.num, rd.from, rd.to, rd.hdg, rd.dist, rd.speed, rd.alt, rd.time, rd.fuel]; + for (var c2 = 0; c2 < numCols; c2++) { + cell(r + 1, c2, String(vals[c2]), false, r % 2 === 1 ? 'rgba(255,255,255,0.03)' : null); + } + } + // Total row: "Total" spans first 4 columns. + var tr = rows.length + 1; + var totalLabel = S.fpTotal; + var totX0 = x; + var totX1 = x + colFrac.slice(0, 4).reduce(function (a, b) { return a + b; }, 0) * w; + var totCY = y + tr * rowH; + ctx.fillStyle = '#333030'; + ctx.fillRect(totX0, totCY, totX1 - totX0, rowH); + ctx.fillStyle = '#e8e8e8'; + ctx.font = 'bold ' + fontSize + 'px sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(totalLabel, totX0 + padX, totCY + rowH / 2); + // Grid lines for total-row cells. + for (var c3 = 0; c3 < numCols; c3++) { + var cx3 = x + colFrac.slice(0, c3).reduce(function (a, b) { return a + b; }, 0) * w; + ctx.strokeStyle = '#4a4646'; + ctx.lineWidth = 0.5; + if (c3 < numCols - 1) { ctx.beginPath(); ctx.moveTo(cx3 + colFrac[c3] * w, totCY); ctx.lineTo(cx3 + colFrac[c3] * w, totCY + rowH); ctx.stroke(); } + ctx.beginPath(); ctx.moveTo(cx3, totCY + rowH); ctx.lineTo(cx3 + colFrac[c3] * w, totCY + rowH); ctx.stroke(); + } + // Remaining total cells. + var totVals = { 4: totDist.toFixed(1), 7: totTime > 0 ? toHMS(totTime) : '--', 8: ac ? totFuel.toFixed(1) : '--' }; + for (var c4 = 4; c4 < numCols; c4++) { + if (totVals[c4] !== undefined) cell(tr, c4, String(totVals[c4]), true, null); + } + ctx.restore(); +} + // Save the framed map + route as a PNG, rendered at the highest practical // native tile zoom (not the on-screen zoom) for maximum quality. flight-maps // tiles are not CORS-enabled, so each tile is fetched through the weserv image @@ -1379,6 +1569,22 @@ function exportPNG() { octx = prevOctx; } + // Pinned flight plan overlay. + var planPinned = localStorage.getItem('navaid.planPin') === '1'; + if (planPinned) { + var fpBox = document.querySelector('.modal-back.flight-plan > .modal'); + if (fpBox) { + var fpRect = fpBox.getBoundingClientRect(); + var fpx = (fpRect.left - fr.x) * s; + var fpy = (fpRect.top - fr.y) * s; + var fpw = fpRect.width * s; + var fph = fpRect.height * s; + if (fpx + fpw > 0 && fpy + fph > 0 && fpx < W && fpy < H) { + drawFlightPlanTable(o, Math.max(0, fpx), Math.max(0, fpy), Math.min(fpw, W - fpx), Math.min(fph, H - fpy)); + } + } + } + out.toBlob(b => { btn.textContent = btnLabel; btn.disabled = false; diff --git a/docs/style.css b/docs/style.css index 9aaf38e5..7b58a842 100644 --- a/docs/style.css +++ b/docs/style.css @@ -861,6 +861,45 @@ html, body { } .modal-close-x:hover { background: #443f3f; color: #fff; } +.modal-pin { + position: absolute; + top: 6px; + inset-inline-end: 38px; + width: 28px; + height: 28px; + font: inherit; + font-size: 14px; + line-height: 1; + color: #b9b3b3; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + z-index: 1; +} +.modal-pin:hover { background: #443f3f; color: #fff; } +.modal-pin.active { color: #ffd966; } + +.resize-handle { + position: absolute; + bottom: 0; + right: 0; + width: 18px; + height: 18px; + cursor: nwse-resize; + z-index: 1; +} +.resize-handle::after { + content: ''; + position: absolute; + bottom: 3px; + right: 3px; + width: 10px; + height: 10px; + border-right: 2px solid #888; + border-bottom: 2px solid #888; +} + /* Floating search overlay — Ctrl/Cmd-F shows this top-center panel so the * user can find a nav-waypoint without expanding the Build section. */ #search-overlay { diff --git a/tests/export-png-options.spec.js b/tests/export-png-options.spec.js index 0d8af20a..d68b146e 100644 --- a/tests/export-png-options.spec.js +++ b/tests/export-png-options.spec.js @@ -278,4 +278,27 @@ test.describe('Export PNG options modal', () => { const download = await dl; expect(download.suggestedFilename()).toMatch(/^navigation-.+\.png$/); }); + + test('Export includes pinned flight plan', async ({ page }) => { + await boot(page); + await page.evaluate(() => { + state.waypoints = [ + { lat: 32.18060, lng: 34.83470, name: 'LLHZ' }, + { lat: 32.80972, lng: 35.04389, name: 'LLHA' }, + ]; + syncLegs(); draw(); + }); + // Open flight plan and pin it. + await page.locator('#plan').click(); + await page.locator('.modal-pin').click(); + // Close the plan. + await page.locator('.modal-close-x').click(); + // Open the export modal and export. + const dl = page.waitForEvent('download', { timeout: 30000 }); + await page.locator('#print').click(); + await page.locator('.modal-back').waitFor(); + await page.locator('.modal .modal-btns button').first().click(); + const download = await dl; + expect(download.suggestedFilename()).toMatch(/^navigation-.+\.png$/); + }); }); diff --git a/tests/flight-plan.spec.js b/tests/flight-plan.spec.js index 3cb5e6c1..4835c479 100644 --- a/tests/flight-plan.spec.js +++ b/tests/flight-plan.spec.js @@ -368,6 +368,47 @@ test.describe('Flight plan', () => { } }); + test('Pin button exists on flight plan and toggles pinned state', async ({ page }) => { + await page.locator('#plan').click(); + const modal = page.locator('.modal-back.flight-plan'); + await expect(modal).toBeVisible(); + const pinBtn = modal.locator('.modal-pin'); + await expect(pinBtn).toBeVisible(); + // Default unpinned. + expect(await pinBtn.getAttribute('title')).toBe('Pin'); + expect(await page.evaluate(() => localStorage.getItem('navaid.planPin'))).toBeNull(); + // Click to pin. + await pinBtn.click(); + expect(await pinBtn.getAttribute('title')).toBe('Unpin'); + expect(await page.evaluate(() => localStorage.getItem('navaid.planPin'))).toBe('1'); + // Click to unpin. + await pinBtn.click(); + expect(await pinBtn.getAttribute('title')).toBe('Pin'); + expect(await page.evaluate(() => localStorage.getItem('navaid.planPin'))).toBe('0'); + }); + + test('Pin state survives close/reopen', async ({ page }) => { + await page.locator('#plan').click(); + await page.locator('.modal-pin').click(); + // Close. + await page.locator('.modal-close-x').click(); + await expect(page.locator('.modal-back.flight-plan')).toHaveCount(0); + // Reopen. + await page.locator('#plan').click(); + const modal = page.locator('.modal-back.flight-plan'); + await expect(modal).toBeVisible(); + const pinBtn = modal.locator('.modal-pin'); + expect(await pinBtn.getAttribute('title')).toBe('Unpin'); + expect(await pinBtn.evaluate(el => el.classList.contains('active'))).toBe(true); + }); + + test('Resize handle exists on flight plan', async ({ page }) => { + await page.locator('#plan').click(); + const modal = page.locator('.modal-back.flight-plan'); + await expect(modal).toBeVisible(); + await expect(modal.locator('.resize-handle')).toBeVisible(); + }); + test('drag-handler touch listeners are cleaned up on close', async ({ page }) => { // Stub addEventListener to count the touch listeners attached to window // by the drag block. Open/close 5×; count must not grow. From 5def3d0188f50acc2239cbd0a2dd4a3d8fa4b526 Mon Sep 17 00:00:00 2001 From: Marco Supino Date: Tue, 26 May 2026 13:16:18 +0300 Subject: [PATCH 02/11] Pinned flight plan: four-corner resize, font shrinking, visual feedback - Resize from all four corners (not just bottom-right). - Font size shrinks to fit content when pinned (no scrolling). - Gold border + glow on pinned box to indicate pin state. - Print button hidden when pinned. --- docs/io.js | 88 ++++++++++++++++++++++++++++++--------- docs/style.css | 36 ++++++++++------ tests/flight-plan.spec.js | 5 ++- 3 files changed, 96 insertions(+), 33 deletions(-) diff --git a/docs/io.js b/docs/io.js index 5bbd559e..1af4b70b 100644 --- a/docs/io.js +++ b/docs/io.js @@ -845,36 +845,79 @@ function showFlightPlan() { const PLANPIN_KEY = 'navaid.planPin'; let planPinned = localStorage.getItem(PLANPIN_KEY) === '1'; pinBtn.title = planPinned ? S.fpUnpin : S.fpPin; - if (planPinned) pinBtn.classList.add('active'); + if (planPinned) { pinBtn.classList.add('active'); box.classList.add('pinned'); } + if (planPinned) printBtn.style.display = 'none'; pinBtn.onclick = function () { planPinned = !planPinned; pinBtn.classList.toggle('active', planPinned); + box.classList.toggle('pinned', planPinned); pinBtn.title = planPinned ? S.fpUnpin : S.fpPin; + printBtn.style.display = planPinned ? 'none' : ''; try { localStorage.setItem(PLANPIN_KEY, planPinned ? '1' : '0'); } catch (e) {} + if (planPinned) applyPlanFontSize(); }; box.appendChild(pinBtn); - // Resize handle — bottom-right corner drag to resize. - var rHandle = document.createElement('div'); - rHandle.className = 'resize-handle'; - box.appendChild(rHandle); - (function () { - var rx = 0, ry = 0, rw = 0, rh = 0, rDrag = false; + // Resize handles — all four corners. + var rCleanups = []; + function makeCornerHandle(corner) { + var el = document.createElement('div'); + el.className = 'resize-handle corner-' + corner; + box.appendChild(el); + var rx = 0, ry = 0, rw = 0, rh = 0, rl = 0, rt = 0, rDrag = false; function rStart(cx, cy) { var r = box.getBoundingClientRect(); rx = cx; ry = cy; rw = r.width; rh = r.height; + rl = r.left; rt = r.top; rDrag = true; } + function computePlanFontSize() { + var numRows = (state.legs || []).length + 2; + if (numRows < 1) return 13; + var titleEl = box.querySelector('.modal-title'); + var acEl = box.querySelector('.fp-aircraft'); + var btnsEl = box.querySelector('.modal-btns'); + var titleH = titleEl ? titleEl.offsetHeight : 40; + var acH = acEl ? acEl.offsetHeight : 0; + var btnsH = btnsEl ? btnsEl.offsetHeight : 36; + var avail = box.offsetHeight - titleH - acH - btnsH - 28; + if (avail < 20) return 13; + var rowH = avail / numRows; + return Math.max(6, Math.min(13, Math.round(rowH * 0.55))); + } + function applyPlanFontSize() { + if (!box.classList.contains('pinned')) return; + box.style.setProperty('--fp-font-size', computePlanFontSize() + 'px'); + } + function clampViewport(l, t, w, h) { + var maxL = window.innerWidth - w; + var maxT = window.innerHeight - h; + l = Math.max(0, Math.min(maxL, l)); + t = Math.max(0, Math.min(maxT, t)); + return { l: l, t: t, w: w, h: h }; + } function rMove(cx, cy) { if (!rDrag) return; - box.style.width = Math.max(300, rw + cx - rx) + 'px'; - box.style.height = Math.max(200, rh + cy - ry) + 'px'; + var dx = cx - rx, dy = cy - ry; + var nl = rl, nt = rt, nw = rw, nh = rh; + if (corner.indexOf('e') !== -1) nw = Math.max(300, rw + dx); + if (corner.indexOf('w') !== -1) { nw = Math.max(300, rw - dx); nl = rl + (rw - nw); } + if (corner.indexOf('s') !== -1) nh = Math.max(200, rh + dy); + if (corner.indexOf('n') !== -1) { nh = Math.max(200, rh - dy); nt = rt + (rh - nh); } + var c = clampViewport(nl, nt, nw, nh); + box.style.left = c.l + 'px'; + box.style.top = c.t + 'px'; + box.style.width = c.w + 'px'; + box.style.height = c.h + 'px'; + box.style.margin = '0'; + applyPlanFontSize(); } function rEnd() { if (!rDrag) return; rDrag = false; - try { localStorage.setItem('navaid.planW', box.offsetWidth); localStorage.setItem('navaid.planH', box.offsetHeight); } catch (e) {} + try { localStorage.setItem('navaid.planW', box.offsetWidth); localStorage.setItem('navaid.planH', box.offsetHeight); + var rect = box.getBoundingClientRect(); localStorage.setItem('navaid.fpPos', JSON.stringify({ x: rect.left, y: rect.top })); } catch (e) {} } function rTouchStart(e) { if (e.touches.length !== 1) return; @@ -886,7 +929,7 @@ function showFlightPlan() { e.preventDefault(); rMove(e.touches[0].clientX, e.touches[0].clientY); } - rHandle.addEventListener('mousedown', function (e) { + el.addEventListener('mousedown', function (e) { e.preventDefault(); rStart(e.clientX, e.clientY); var onMove = function (ev) { rMove(ev.clientX, ev.clientY); }; @@ -894,20 +937,27 @@ function showFlightPlan() { window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }); - rHandle.addEventListener('touchstart', rTouchStart, { passive: false }); + el.addEventListener('touchstart', rTouchStart, { passive: false }); window.addEventListener('touchmove', rTouchMove, { passive: false }); window.addEventListener('touchend', rEnd); window.addEventListener('touchcancel', rEnd); - // Extend flightPlanCleanup with resize-teardown. - var prevCleanup = flightPlanCleanup; - flightPlanCleanup = function () { - if (typeof prevCleanup === 'function') prevCleanup(); - rHandle.removeEventListener('touchstart', rTouchStart, { passive: false }); + rCleanups.push(function () { + el.removeEventListener('touchstart', rTouchStart, { passive: false }); window.removeEventListener('touchmove', rTouchMove, { passive: false }); window.removeEventListener('touchend', rEnd); window.removeEventListener('touchcancel', rEnd); - }; - })(); + }); + } + makeCornerHandle('se'); + makeCornerHandle('sw'); + makeCornerHandle('ne'); + makeCornerHandle('nw'); + // Extend flightPlanCleanup with resize-teardown. + var prevCleanup = flightPlanCleanup; + flightPlanCleanup = function () { + if (typeof prevCleanup === 'function') prevCleanup(); + for (var i = 0; i < rCleanups.length; i++) rCleanups[i](); + }; // Restore saved size. try { diff --git a/docs/style.css b/docs/style.css index 7b58a842..5b1475ed 100644 --- a/docs/style.css +++ b/docs/style.css @@ -648,6 +648,17 @@ html, body { flex: 1; min-height: 0; } +.modal.pinned .fp-scroll { + overflow: hidden; +} +.modal.pinned { + border-color: #ffd966; + box-shadow: 0 0 0 2px rgba(255, 217, 102, 0.3); +} +.modal.pinned .flight-table, +.modal.pinned .flight-table input { + font-size: var(--fp-font-size, 13px); +} .flight-table { width: 100%; border-collapse: collapse; @@ -882,23 +893,24 @@ html, body { .resize-handle { position: absolute; - bottom: 0; - right: 0; - width: 18px; - height: 18px; - cursor: nwse-resize; + width: 16px; + height: 16px; z-index: 1; } .resize-handle::after { content: ''; position: absolute; - bottom: 3px; - right: 3px; - width: 10px; - height: 10px; - border-right: 2px solid #888; - border-bottom: 2px solid #888; -} + width: 8px; + height: 8px; +} +.resize-handle.corner-se { bottom: 0; right: 0; cursor: nwse-resize; } +.resize-handle.corner-se::after { bottom: 2px; right: 2px; border-right: 2px solid #888; border-bottom: 2px solid #888; } +.resize-handle.corner-sw { bottom: 0; left: 0; cursor: nesw-resize; } +.resize-handle.corner-sw::after { bottom: 2px; left: 2px; border-left: 2px solid #888; border-bottom: 2px solid #888; } +.resize-handle.corner-ne { top: 0; right: 0; cursor: nesw-resize; } +.resize-handle.corner-ne::after { top: 2px; right: 2px; border-right: 2px solid #888; border-top: 2px solid #888; } +.resize-handle.corner-nw { top: 0; left: 0; cursor: nwse-resize; } +.resize-handle.corner-nw::after { top: 2px; left: 2px; border-left: 2px solid #888; border-top: 2px solid #888; } /* Floating search overlay — Ctrl/Cmd-F shows this top-center panel so the * user can find a nav-waypoint without expanding the Build section. */ diff --git a/tests/flight-plan.spec.js b/tests/flight-plan.spec.js index 4835c479..eed75b49 100644 --- a/tests/flight-plan.spec.js +++ b/tests/flight-plan.spec.js @@ -402,11 +402,12 @@ test.describe('Flight plan', () => { expect(await pinBtn.evaluate(el => el.classList.contains('active'))).toBe(true); }); - test('Resize handle exists on flight plan', async ({ page }) => { + test('Four resize handles exist on flight plan', async ({ page }) => { await page.locator('#plan').click(); const modal = page.locator('.modal-back.flight-plan'); await expect(modal).toBeVisible(); - await expect(modal.locator('.resize-handle')).toBeVisible(); + const handles = modal.locator('.resize-handle'); + await expect(handles).toHaveCount(4); }); test('drag-handler touch listeners are cleaned up on close', async ({ page }) => { From c4c58a1c2c7c2bc0f86f0bd12ff6d32e9dceb85b Mon Sep 17 00:00:00 2001 From: Marco Supino Date: Tue, 26 May 2026 13:19:15 +0300 Subject: [PATCH 03/11] Block GA during Lighthouse CI audits Add --blocked-url-patterns to Lighthouse CLI so GA/Tag Manager requests don't fire during CI runs, avoiding unnecessary GA quota consumption. --- .github/workflows/review.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 3e270b00..ed2bbeff 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -80,6 +80,7 @@ jobs: run: | npx --yes lighthouse@12 http://localhost:8080 \ --chrome-flags="--headless --no-sandbox --disable-gpu" \ + --blocked-url-patterns="googletagmanager|google-analytics|doubleclick" \ --output=json --output=html --output-path=./lighthouse \ --only-categories=performance,accessibility,best-practices,seo - uses: actions/upload-artifact@v4 From 4a1477e00f730db71538fd22f2e30dff518bbf23 Mon Sep 17 00:00:00 2001 From: Marco Supino Date: Tue, 26 May 2026 13:21:55 +0300 Subject: [PATCH 04/11] Fix font-shrink scoping, lower font floor to 4px - Move computePlanFontSize / applyPlanFontSize to outer scope so pinBtn.onclick can call them (was trapped inside makeCornerHandle). - Lower minimum font size from 6px to 4px so content fits without scrolling even on very small boxes. --- docs/io.js | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/io.js b/docs/io.js index 1af4b70b..7dcf17d0 100644 --- a/docs/io.js +++ b/docs/io.js @@ -859,6 +859,24 @@ function showFlightPlan() { box.appendChild(pinBtn); // Resize handles — all four corners. + function computePlanFontSize() { + var numRows = (state.legs || []).length + 2; + if (numRows < 1) return 13; + var titleEl = box.querySelector('.modal-title'); + var acEl = box.querySelector('.fp-aircraft'); + var btnsEl = box.querySelector('.modal-btns'); + var titleH = titleEl ? titleEl.offsetHeight : 40; + var acH = acEl ? acEl.offsetHeight : 0; + var btnsH = btnsEl ? btnsEl.offsetHeight : 36; + var avail = box.offsetHeight - titleH - acH - btnsH - 28; + if (avail < 8) return 4; + var rowH = avail / numRows; + return Math.max(4, Math.min(13, Math.round(rowH * 0.55))); + } + function applyPlanFontSize() { + if (!box.classList.contains('pinned')) return; + box.style.setProperty('--fp-font-size', computePlanFontSize() + 'px'); + } var rCleanups = []; function makeCornerHandle(corner) { var el = document.createElement('div'); @@ -872,24 +890,6 @@ function showFlightPlan() { rl = r.left; rt = r.top; rDrag = true; } - function computePlanFontSize() { - var numRows = (state.legs || []).length + 2; - if (numRows < 1) return 13; - var titleEl = box.querySelector('.modal-title'); - var acEl = box.querySelector('.fp-aircraft'); - var btnsEl = box.querySelector('.modal-btns'); - var titleH = titleEl ? titleEl.offsetHeight : 40; - var acH = acEl ? acEl.offsetHeight : 0; - var btnsH = btnsEl ? btnsEl.offsetHeight : 36; - var avail = box.offsetHeight - titleH - acH - btnsH - 28; - if (avail < 20) return 13; - var rowH = avail / numRows; - return Math.max(6, Math.min(13, Math.round(rowH * 0.55))); - } - function applyPlanFontSize() { - if (!box.classList.contains('pinned')) return; - box.style.setProperty('--fp-font-size', computePlanFontSize() + 'px'); - } function clampViewport(l, t, w, h) { var maxL = window.innerWidth - w; var maxT = window.innerHeight - h; From 9ff9f0f57014078b8cc036f4141a88de5ecd506b Mon Sep 17 00:00:00 2001 From: Marco Supino Date: Tue, 26 May 2026 13:23:03 +0300 Subject: [PATCH 05/11] =?UTF-8?q?Pinned:=20hide=20title,=20aircraft,=20but?= =?UTF-8?q?tons=20=E2=80=94=20only=20table=20remains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/style.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/style.css b/docs/style.css index 5b1475ed..1177bcf0 100644 --- a/docs/style.css +++ b/docs/style.css @@ -659,6 +659,14 @@ html, body { .modal.pinned .flight-table input { font-size: var(--fp-font-size, 13px); } +.modal.pinned .fp-aircraft, +.modal.pinned .modal-btns, +.modal.pinned .modal-title { + display: none; +} +.modal.pinned .flight-table { + margin-bottom: 0; +} .flight-table { width: 100%; border-collapse: collapse; From ba01e249fa3553ba6c5cc72f06885c01e622de5b Mon Sep 17 00:00:00 2001 From: Marco Supino Date: Tue, 26 May 2026 13:26:19 +0300 Subject: [PATCH 06/11] Tighten pinned spacing: minimal padding, no borders, fractional font floor 2px --- docs/io.js | 6 +++--- docs/style.css | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/io.js b/docs/io.js index 7dcf17d0..9c00ecf8 100644 --- a/docs/io.js +++ b/docs/io.js @@ -845,7 +845,7 @@ function showFlightPlan() { const PLANPIN_KEY = 'navaid.planPin'; let planPinned = localStorage.getItem(PLANPIN_KEY) === '1'; pinBtn.title = planPinned ? S.fpUnpin : S.fpPin; - if (planPinned) { pinBtn.classList.add('active'); box.classList.add('pinned'); } + if (planPinned) { pinBtn.classList.add('active'); box.classList.add('pinned'); applyPlanFontSize(); } if (planPinned) printBtn.style.display = 'none'; pinBtn.onclick = function () { planPinned = !planPinned; @@ -869,9 +869,9 @@ function showFlightPlan() { var acH = acEl ? acEl.offsetHeight : 0; var btnsH = btnsEl ? btnsEl.offsetHeight : 36; var avail = box.offsetHeight - titleH - acH - btnsH - 28; - if (avail < 8) return 4; + if (avail < 6) return 2; var rowH = avail / numRows; - return Math.max(4, Math.min(13, Math.round(rowH * 0.55))); + return Math.max(2, Math.min(13, rowH - 2)); } function applyPlanFontSize() { if (!box.classList.contains('pinned')) return; diff --git a/docs/style.css b/docs/style.css index 1177bcf0..24f5e64c 100644 --- a/docs/style.css +++ b/docs/style.css @@ -664,8 +664,30 @@ html, body { .modal.pinned .modal-title { display: none; } +.modal.pinned .flight-table th, +.modal.pinned .flight-table td { + padding: 1px 3px; + border-bottom: none; +} +.modal.pinned .flight-table th { + position: static; + font-weight: 600; +} +.modal.pinned .flight-table input { + width: auto; + padding: 0 2px; + border: none; + background: transparent; + border-radius: 0; + min-width: 0; +} +.modal.pinned .flight-table tfoot td { + border-top: none; + font-weight: 700; +} .modal.pinned .flight-table { margin-bottom: 0; + border-collapse: collapse; } .flight-table { width: 100%; From 76c9a4d775cdc268006eb0d7c2dbc6c3d4a72244 Mon Sep 17 00:00:00 2001 From: Marco Supino Date: Tue, 26 May 2026 13:27:25 +0300 Subject: [PATCH 07/11] Pinned: hide close X button --- docs/style.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/style.css b/docs/style.css index 24f5e64c..8f84c8a3 100644 --- a/docs/style.css +++ b/docs/style.css @@ -661,7 +661,8 @@ html, body { } .modal.pinned .fp-aircraft, .modal.pinned .modal-btns, -.modal.pinned .modal-title { +.modal.pinned .modal-title, +.modal.pinned .modal-close-x { display: none; } .modal.pinned .flight-table th, From 59d21ecadafb68b377d4d609644204bd1cc5cb6b Mon Sep 17 00:00:00 2001 From: Marco Supino Date: Tue, 26 May 2026 13:30:32 +0300 Subject: [PATCH 08/11] Unpin restores original box size, clears --fp-font-size --- docs/io.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/io.js b/docs/io.js index 9c00ecf8..54b48e25 100644 --- a/docs/io.js +++ b/docs/io.js @@ -854,7 +854,14 @@ function showFlightPlan() { pinBtn.title = planPinned ? S.fpUnpin : S.fpPin; printBtn.style.display = planPinned ? 'none' : ''; try { localStorage.setItem(PLANPIN_KEY, planPinned ? '1' : '0'); } catch (e) {} - if (planPinned) applyPlanFontSize(); + if (planPinned) { + applyPlanFontSize(); + } else { + box.style.width = ''; + box.style.height = ''; + box.style.removeProperty('--fp-font-size'); + try { localStorage.removeItem('navaid.planW'); localStorage.removeItem('navaid.planH'); } catch (e) {} + } }; box.appendChild(pinBtn); @@ -871,7 +878,7 @@ function showFlightPlan() { var avail = box.offsetHeight - titleH - acH - btnsH - 28; if (avail < 6) return 2; var rowH = avail / numRows; - return Math.max(2, Math.min(13, rowH - 2)); + return Math.max(2, Math.min(13, rowH - 0.3)); } function applyPlanFontSize() { if (!box.classList.contains('pinned')) return; From 614a569f2a8e31f70ae14a6463d94f4ab343570c Mon Sep 17 00:00:00 2001 From: Marco Supino Date: Tue, 26 May 2026 13:41:04 +0300 Subject: [PATCH 09/11] Pinned: table-layout fixed + min-width:0 on fp-scroll prevents hidden right columns --- docs/style.css | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/style.css b/docs/style.css index 8f84c8a3..69aa978a 100644 --- a/docs/style.css +++ b/docs/style.css @@ -650,6 +650,7 @@ html, body { } .modal.pinned .fp-scroll { overflow: hidden; + min-width: 0; } .modal.pinned { border-color: #ffd966; @@ -667,8 +668,10 @@ html, body { } .modal.pinned .flight-table th, .modal.pinned .flight-table td { - padding: 1px 3px; - border-bottom: none; + padding: 0 2px; + border: none; + line-height: 1; + white-space: nowrap; } .modal.pinned .flight-table th { position: static; @@ -676,19 +679,32 @@ html, body { } .modal.pinned .flight-table input { width: auto; - padding: 0 2px; + padding: 0; border: none; background: transparent; border-radius: 0; min-width: 0; + height: auto; + line-height: 1; } .modal.pinned .flight-table tfoot td { - border-top: none; + border: none; font-weight: 700; } .modal.pinned .flight-table { margin-bottom: 0; border-collapse: collapse; + table-layout: fixed; + width: 100%; +} +.modal.pinned .flight-table th, +.modal.pinned .flight-table td { + padding: 0 2px; + border: none; + line-height: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .flight-table { width: 100%; From fe14ca3b2487480c4f6764dbaadc475c097e705b Mon Sep 17 00:00:00 2001 From: Marco Supino Date: Tue, 26 May 2026 13:41:20 +0300 Subject: [PATCH 10/11] Pinned: max-width:100% on table inputs prevents cell overflow --- docs/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/style.css b/docs/style.css index 69aa978a..f314b265 100644 --- a/docs/style.css +++ b/docs/style.css @@ -679,6 +679,7 @@ html, body { } .modal.pinned .flight-table input { width: auto; + max-width: 100%; padding: 0; border: none; background: transparent; From 89f4f5a76adad7b943e0aa3732d47b702babd1b2 Mon Sep 17 00:00:00 2001 From: Marco Supino Date: Tue, 26 May 2026 13:50:07 +0300 Subject: [PATCH 11/11] retry CI