diff --git a/docs/core.js b/docs/core.js index a7dab5b4..c001afc4 100644 --- a/docs/core.js +++ b/docs/core.js @@ -92,11 +92,14 @@ window.S = Object.assign({ errNoLegs: 'No legs yet — drop at least two waypoints first.', flightPlan: 'Flight plan', fpHeaders: ['#', 'From', 'To', 'Hdg', 'Dist (NM)', 'Speed (kt)', 'Alt (ft)', 'Time', 'Fuel (gal)', 'Cum. time', 'Cum. fuel', ''], + fpHeadersShort: ['#', 'From', 'To', 'Hdg', 'Dist', 'Spd', 'Alt', 'Time', 'Fuel'], fpDel: '✕', fpReturn: 'Return route', fpTotal: 'Total', fpClose: 'Close', fpPrint: 'Print', + fpPin: 'Pin', + fpUnpin: 'Unpin', fpFuel: 'Fuel', tbAircraft: 'Aircraft', tbGph: 'Gallons per hour', @@ -275,6 +278,17 @@ window.S = Object.assign({ exportNoPageWarn: 'No page size selected — exported image ratio may not match a print page.', exportLayer: 'Layer', exportBtn: 'Export', + addPlanToMap: 'Add plan to map', + addPlanToMapTitle: 'Drop a draggable, resizable placeholder on the map; the flight plan table will be drawn into it on export.', + removePlanFromMap: 'Remove plan placeholder', + planPlaceholderLabel: 'PLAN', + planPlacementLabel: 'Flight plan on print:', + planPlacementNone: 'None', + planPlacementTL: '↖ TL', + planPlacementTR: '↗ TR', + planPlacementBL: '↙ BL', + planPlacementBR: '↘ BR', + planPlacementCustom: 'Custom…', }, window.S || {}); // Fill data-i18n / data-i18n-title / data-i18n-placeholder / data-i18n-aria @@ -347,13 +361,27 @@ var pageOrient = 'portrait'; let pageOffset = { x: 0, y: 0 }; // page-frame drag offset from viewport centre var aircraft = null; // null | {gph, taxiGal} +// Aircraft fuel-calc defaults. Used both by the Flight Plan modal's +// aircraft inputs and by drawFlightPlanTable when the modal hasn't +// been opened yet — without this, a PNG export taken straight from +// the toolbar would render Fuel as '--' because aircraft was null. +const AIRCRAFT_DEFAULTS = { gph: 8, taxiGal: 1.1 }; + function loadAircraft() { try { const raw = localStorage.getItem('navaid.aircraft'); if (raw) aircraft = JSON.parse(raw); } catch (e) { /* storage unavailable */ } + if (!aircraft || typeof aircraft.gph !== 'number') { + aircraft = Object.assign({}, AIRCRAFT_DEFAULTS); + } } +// Eager-load at module parse time so any code path that reads +// `aircraft` before the Flight Plan modal opens still sees the +// defaults — drawFlightPlanTable (PNG export) is the main case. +loadAircraft(); + function saveAircraft() { try { localStorage.setItem('navaid.aircraft', JSON.stringify(aircraft)); } catch (e) {} } diff --git a/docs/he/strings.js b/docs/he/strings.js index 0e7ae023..ffcdc098 100644 --- a/docs/he/strings.js +++ b/docs/he/strings.js @@ -61,6 +61,8 @@ window.S = { fpReturn: 'מסלול חזרה', fpClose: 'סגור', fpPrint: 'הדפס', + fpPin: 'נעץ', + fpUnpin: 'שחרר', fpFuel: 'דלק', tbAircraft: 'כלי טיס', tbGph: 'גלונים לשעה', @@ -226,6 +228,17 @@ window.S = { exportNoPageWarn: 'לא נבחר גודל דף — יחס התמונה המיוצאת עשוי שלא להתאים להדפסה.', exportLayer: 'שכבת מפה', exportBtn: 'ייצא', + addPlanToMap: 'הוסף מסלול למפה', + addPlanToMapTitle: 'מיקום מציין-מקום הניתן לגרירה ולשינוי גודל על המפה; טבלת תכנית הטיסה תצויר לתוכו בייצוא.', + removePlanFromMap: 'הסר את מציין-המקום', + planPlaceholderLabel: 'מסלול', + planPlacementLabel: 'מיקום תכנית הטיסה בהדפסה:', + planPlacementNone: 'ללא', + planPlacementTL: '↖ ש"ע', + planPlacementTR: '↗ י"ע', + planPlacementBL: '↙ ש"ת', + planPlacementBR: '↘ י"ת', + planPlacementCustom: 'מותאם…', // --- Magnifying glass ------------------------------------------------ tbMagnifier: '🔍 זכוכית מגדלת (M)', diff --git a/docs/io.js b/docs/io.js index cfdee537..b903e52f 100644 --- a/docs/io.js +++ b/docs/io.js @@ -1255,6 +1255,16 @@ function showFlightPlan() { box.appendChild(btns); addModalCloseX(box, closeFlightPlan); + // Pin-to-map UX retired (PR #338). Plan placement is now chosen from + // the Export PNG modal's 'Flight plan placement' row. The flight-plan + // modal stays a plain editor with no pin / drag / resize hooks. + // Clean stale localStorage from older pin-mode versions. + try { + localStorage.removeItem('navaid.planPin'); + localStorage.removeItem('navaid.planW'); + localStorage.removeItem('navaid.planH'); + } catch (e) { /* storage unavailable */ } + back.appendChild(box); // Close via the Close button or Escape (#86). document.body.appendChild(back); @@ -1349,6 +1359,228 @@ function fileStamp() { .replace(/[-:]/g, '').replace('T', '-'); } +// Per-session draggable / resizable wireframe placeholder that marks where +// the flight-plan table should be drawn into a PNG export. Lives alongside +// the legacy "pin plan to map" feature; the placeholder is in-memory only +// (no localStorage) and is recreated each time the user requests it from +// the Export PNG dialog. When present, exportPNG() uses the placeholder's +// viewport rect instead of the pinned-plan rect. +var planPlaceholderEl = null; +// 'tl' | 'tr' | 'bl' | 'br' | 'center' — set by the placement picker so +// exportPNG can anchor the rendered table to the same corner. 'center' +// is also used for the Custom (free-drag) option. +var planPlacementAlign = 'center'; + +function planPlaceholderActive() { + return !!(planPlaceholderEl && planPlaceholderEl.isConnected); +} + +function removePlanPlaceholder() { + if (planPlaceholderEl && planPlaceholderEl.parentNode) { + planPlaceholderEl.parentNode.removeChild(planPlaceholderEl); + } + planPlaceholderEl = null; +} + +// Compute placeholder rect (px, screen coords) for a placement choice: +// 'tl' | 'tr' | 'bl' | 'br' — corner of the page frame (or viewport +// when no A3/A4 frame is set). 'custom' or undefined → default centre. +// Box is sized ~28 % of the host rect's width, ~25 % of its height, +// clamped to a minimum that's still readable when rendered. +function placeholderRectForPlacement(placement) { + // Host = page frame if present, else the visible viewport. + var host = pageFrameRect(); + if (!host) host = { x: 0, y: 0, w: window.innerWidth, h: window.innerHeight }; + var w = Math.max(180, Math.round(host.w * 0.28)); + var h = Math.max(110, Math.round(host.h * 0.25)); + var margin = 12; + var left, top; + switch (placement) { + case 'tl': left = host.x + margin; top = host.y + margin; break; + case 'tr': left = host.x + host.w - w - margin; top = host.y + margin; break; + case 'bl': left = host.x + margin; top = host.y + host.h - h - margin; break; + case 'br': left = host.x + host.w - w - margin; top = host.y + host.h - h - margin; break; + default: + left = host.x + (host.w - w) / 2; + top = host.y + (host.h - h) / 2; + } + // Clamp to viewport so corners outside the visible map area still land + // somewhere reachable. + left = Math.max(0, Math.min(window.innerWidth - w, left)); + top = Math.max(0, Math.min(window.innerHeight - h, top)); + return { left: left, top: top, w: w, h: h }; +} + +function createPlanPlaceholder(placement) { + removePlanPlaceholder(); + var el = document.createElement('div'); + el.className = 'plan-placeholder'; + var r = placeholderRectForPlacement(placement); + el.style.left = r.left + 'px'; + el.style.top = r.top + 'px'; + el.style.width = r.w + 'px'; + el.style.height = r.h + 'px'; + + // Header strip: "PLAN" label on the left, × close on the right. + var hdr = document.createElement('div'); + hdr.className = 'plan-placeholder-hdr'; + var lbl = document.createElement('span'); + lbl.className = 'plan-placeholder-label'; + lbl.textContent = (window.S && S.planPlaceholderLabel) || 'PLAN'; + hdr.appendChild(lbl); + var closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'plan-placeholder-close'; + closeBtn.title = (window.S && S.removePlanFromMap) || 'Remove'; + closeBtn.setAttribute('aria-label', closeBtn.title); + closeBtn.textContent = '\u00D7'; + closeBtn.onclick = function (e) { + e.stopPropagation(); + removePlanPlaceholder(); + }; + hdr.appendChild(closeBtn); + el.appendChild(hdr); + + // Wireframe rows — grey bars on a card background. Pure visual mass; no + // real data, no headers, no row text. Number of bars (9) ≈ a typical + // flight-plan with a header + 7 legs + a total row. + var body = document.createElement('div'); + body.className = 'plan-placeholder-body'; + for (var i = 0; i < 9; i++) { + var bar = document.createElement('div'); + bar.className = 'plan-placeholder-bar'; + body.appendChild(bar); + } + el.appendChild(body); + + // Drag — anywhere on the element except the close button and the resize + // handles. Same clamp-to-viewport pattern the pin feature uses. + var dragging = false, dX = 0, dY = 0, dL = 0, dT = 0; + function dragStart(cx, cy) { + var r = el.getBoundingClientRect(); + dragging = true; + dX = cx; dY = cy; dL = r.left; dT = r.top; + } + function dragMove(cx, cy) { + if (!dragging) return; + var nl = dL + (cx - dX); + var nt = dT + (cy - dY); + nl = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, nl)); + nt = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, nt)); + el.style.left = nl + 'px'; + el.style.top = nt + 'px'; + } + function dragEnd() { dragging = false; } + el.addEventListener('mousedown', function (e) { + var t = e.target; + if (t.closest('.plan-placeholder-close')) return; + if (t.closest('.resize-handle')) return; + e.preventDefault(); + dragStart(e.clientX, e.clientY); + var onMove = function (ev) { dragMove(ev.clientX, ev.clientY); }; + var onUp = function () { + dragEnd(); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }); + el.addEventListener('touchstart', function (e) { + if (e.touches.length !== 1) return; + var t = e.target; + if (t.closest('.plan-placeholder-close')) return; + if (t.closest('.resize-handle')) return; + e.preventDefault(); + dragStart(e.touches[0].clientX, e.touches[0].clientY); + }, { passive: false }); + var onTouchMove = function (e) { + if (!dragging || e.touches.length !== 1) return; + e.preventDefault(); + dragMove(e.touches[0].clientX, e.touches[0].clientY); + }; + window.addEventListener('touchmove', onTouchMove, { passive: false }); + window.addEventListener('touchend', dragEnd); + window.addEventListener('touchcancel', dragEnd); + + // Four-corner resize handles — same .resize-handle / .corner-* classes the + // pinned-plan box uses, so the existing CSS styles them automatically. + function makeHandle(corner) { + var h = document.createElement('div'); + h.className = 'resize-handle corner-' + corner; + el.appendChild(h); + var rx = 0, ry = 0, rw = 0, rh = 0, rl = 0, rt = 0, rDrag = false; + function rStart(cx, cy) { + var r = el.getBoundingClientRect(); + rx = cx; ry = cy; rw = r.width; rh = r.height; rl = r.left; rt = r.top; + rDrag = true; + } + function rMove(cx, cy) { + if (!rDrag) return; + var dx = cx - rx, dy = cy - ry; + var nl = rl, nt = rt, nw = rw, nh = rh; + if (corner.indexOf('e') !== -1) nw = Math.max(80, rw + dx); + if (corner.indexOf('w') !== -1) { nw = Math.max(80, rw - dx); nl = rl + (rw - nw); } + if (corner.indexOf('s') !== -1) nh = Math.max(60, rh + dy); + if (corner.indexOf('n') !== -1) { nh = Math.max(60, rh - dy); nt = rt + (rh - nh); } + nl = Math.max(0, Math.min(window.innerWidth - nw, nl)); + nt = Math.max(0, Math.min(window.innerHeight - nh, nt)); + el.style.left = nl + 'px'; + el.style.top = nt + 'px'; + el.style.width = nw + 'px'; + el.style.height = nh + 'px'; + } + function rEnd() { rDrag = false; } + h.addEventListener('mousedown', function (e) { + e.preventDefault(); + e.stopPropagation(); + 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); + }); + h.addEventListener('touchstart', function (e) { + if (e.touches.length !== 1) return; + e.preventDefault(); + e.stopPropagation(); + rStart(e.touches[0].clientX, e.touches[0].clientY); + }, { passive: false }); + var rTM = function (e) { + if (!rDrag || e.touches.length !== 1) return; + e.preventDefault(); + rMove(e.touches[0].clientX, e.touches[0].clientY); + }; + window.addEventListener('touchmove', rTM, { passive: false }); + window.addEventListener('touchend', rEnd); + window.addEventListener('touchcancel', rEnd); + } + makeHandle('se'); + makeHandle('sw'); + makeHandle('ne'); + makeHandle('nw'); + + document.body.appendChild(el); + planPlaceholderEl = el; + return el; +} + +function togglePlanPlaceholder() { + if (planPlaceholderActive()) { removePlanPlaceholder(); return false; } + createPlanPlaceholder(); + return true; +} + +// Set / move the placeholder to a named placement. Pass null to remove. +function setPlanPlacement(placement) { + if (!placement) { removePlanPlaceholder(); return; } + createPlanPlaceholder(placement); +} + // Show a pre-export modal so the user can decide which overlays and base // layer appear in the PNG, independently of the current screen settings. function showExportModal() { @@ -1480,6 +1712,56 @@ function showExportModal() { } body.appendChild(pageWarn); + // Flight-plan placement on the export. One row, 6 options: + // None / TL / TR / BL / BR / Custom. + // None removes any existing placeholder; corner picks place a + // wireframe rectangle in that corner of the page frame (or the + // viewport if no A3/A4 frame is set); Custom drops the box at the + // centre and the user can drag / resize it. + const placeholderRow = document.createElement('div'); + placeholderRow.style.cssText = 'display:flex;flex-direction:column;gap:4px;padding-top:6px;margin-top:2px;border-top:1px solid #4a4646'; + const placeholderLbl = document.createElement('div'); + placeholderLbl.style.cssText = 'font-size:12px;color:#b9b3b3'; + placeholderLbl.textContent = (S.planPlacementLabel || 'Flight plan placement:'); + placeholderRow.appendChild(placeholderLbl); + const placeholderBtns = document.createElement('div'); + placeholderBtns.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap'; + const PLACEMENTS = [ + { key: 'none', label: S.planPlacementNone || 'None' }, + { key: 'tl', label: S.planPlacementTL || '↖ TL' }, + { key: 'tr', label: S.planPlacementTR || '↗ TR' }, + { key: 'bl', label: S.planPlacementBL || '↙ BL' }, + { key: 'br', label: S.planPlacementBR || '↘ BR' }, + { key: 'custom', label: S.planPlacementCustom || 'Custom…' }, + ]; + let currentPlacement = 'none'; + const placementBtnEls = {}; + function syncPlacementBtns() { + for (const p of PLACEMENTS) { + placementBtnEls[p.key].classList.toggle('active', p.key === currentPlacement); + } + } + for (const p of PLACEMENTS) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = p.label; + btn.style.cssText = 'font:inherit;font-size:12px;padding:5px 9px;background:#3a3636;color:#e8e8e8;border:1px solid #4a4646;border-radius:4px;cursor:pointer'; + btn.onclick = function () { + currentPlacement = p.key; + planPlacementAlign = (p.key === 'tl' || p.key === 'tr' || + p.key === 'bl' || p.key === 'br') ? p.key : 'center'; + if (p.key === 'none') removePlanPlaceholder(); + else if (p.key === 'custom') createPlanPlaceholder(); // centred + draggable + else setPlanPlacement(p.key); + syncPlacementBtns(); + }; + placementBtnEls[p.key] = btn; + placeholderBtns.appendChild(btn); + } + syncPlacementBtns(); + placeholderRow.appendChild(placeholderBtns); + body.appendChild(placeholderRow); + box.appendChild(body); // Save original state (before applying defaults) so Cancel can restore. @@ -1586,6 +1868,168 @@ 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. +// align: one of 'tl', 'tr', 'bl', 'br', 'center'. Anchors the rendered +// table (which is usually smaller than the host w×h rect) to that +// corner of the host rect. Default 'tl' for backwards compat. +function drawFlightPlanTable(ctx, x, y, w, h, align) { + 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; + // Always use short headers on the printed plan — saves horizontal + // space, matches the compact on-map placeholder size. + const headers = S.fpHeadersShort; + const numCols = headers.length; + const numRows = rows.length + 2; // header + data + total + // Choose font from the available height per row, then clamp the row + // height so we never spread the rows out wider than ~1.35 × font. + // A tall narrow placeholder used to balloon row spacing — now it + // just leaves whitespace at the bottom instead. + const idealRowH = h / numRows; + const fontSize = Math.max(9, Math.min(idealRowH * 0.7, 22)); + const rowH = Math.min(idealRowH, Math.ceil(fontSize * 1.35)); + const padX = Math.max(4, Math.round(fontSize * 0.6)); + // Text alignment per column. + const aligns = ['center', 'left', 'left', 'center', 'right', 'right', 'right', 'center', 'right']; + ctx.save(); + ctx.font = fontSize + 'px sans-serif'; + // Pre-compute pixel column widths from measured content (header in bold, all + // data rows, total values). Each column is content + 2*padX, so adjacent + // cells keep at least ~2*padX (~10 px) of breathing room. This replaces the + // old fixed fractions which left too much empty space in compact rows. + var totVals = { 4: totDist.toFixed(1), 7: totTime > 0 ? toHMS(totTime) : '--', 8: ac ? totFuel.toFixed(1) : '--' }; + const colW = new Array(numCols).fill(0); + ctx.font = 'bold ' + fontSize + 'px sans-serif'; + for (let mc = 0; mc < numCols; mc++) { + colW[mc] = Math.max(colW[mc], ctx.measureText(String(headers[mc])).width); + if (totVals[mc] !== undefined) { + colW[mc] = Math.max(colW[mc], ctx.measureText(String(totVals[mc])).width); + } + } + ctx.font = fontSize + 'px sans-serif'; + for (let mr = 0; mr < rows.length; mr++) { + const rd = rows[mr]; + const mvals = [rd.num, rd.from, rd.to, rd.hdg, rd.dist, rd.speed, rd.alt, rd.time, rd.fuel]; + for (let mc = 0; mc < numCols; mc++) { + colW[mc] = Math.max(colW[mc], ctx.measureText(String(mvals[mc])).width); + } + } + for (let mc = 0; mc < numCols; mc++) colW[mc] = Math.ceil(colW[mc] + 2 * padX); + const colX = new Array(numCols + 1).fill(0); + for (let mc = 0; mc < numCols; mc++) colX[mc + 1] = colX[mc] + colW[mc]; + const totalW = colX[numCols]; + + // Paper-print look: white background, black text, visible grid lines. + const HEADER_BG = '#e8e6e1'; + const TOTAL_BG = '#f0eee9'; + const STRIPE_BG = '#f7f5f0'; + const GRID = '#7a7470'; + const TEXT = '#1a1a1a'; + + // Actual rendered table height (rowH might be less than h/numRows). + const tableH = rowH * numRows; + // Anchor inside the host rect — table may be smaller than (w,h), so + // for BR/BL the caller expects the table at the bottom-right/left, not + // floating at the placeholder's top-left. + const a = align || 'tl'; + if (a === 'tr' || a === 'br') x = x + Math.max(0, w - totalW); + if (a === 'bl' || a === 'br') y = y + Math.max(0, h - tableH); + if (a === 'center') { + x = x + Math.max(0, (w - totalW) / 2); + y = y + Math.max(0, (h - tableH) / 2); + } + ctx.fillStyle = '#ffffff'; + ctx.fillRect(x, y, totalW, tableH); + + // Helper: draw one cell. + function cell(row, col, text, bold, bg) { + const cx = x + colX[col]; + const cy = y + row * rowH; + const cw = colW[col]; + if (bg) { ctx.fillStyle = bg; ctx.fillRect(cx, cy, cw, rowH); } + ctx.fillStyle = TEXT; + ctx.font = (bold ? 'bold ' : '') + fontSize + 'px sans-serif'; + ctx.textBaseline = 'middle'; + const a = aligns[col]; + ctx.textAlign = a; + const tx = a === 'right' ? cx + cw - padX : a === 'center' ? cx + cw / 2 : cx + padX; + ctx.fillText(text, tx, cy + rowH / 2); + } + // Header row background. + ctx.fillStyle = HEADER_BG; + ctx.fillRect(x, y, totalW, rowH); + for (var c = 0; c < numCols; c++) cell(0, c, headers[c], true, null); + // Data rows with alternating stripe. + 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 ? STRIPE_BG : null); + } + } + // Total row. + var tr = rows.length + 1; + var totCY = y + tr * rowH; + ctx.fillStyle = TOTAL_BG; + ctx.fillRect(x, totCY, totalW, rowH); + ctx.fillStyle = TEXT; + ctx.font = 'bold ' + fontSize + 'px sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(S.fpTotal, x + colX[1] + padX, totCY + rowH / 2); + for (var c4 = 4; c4 < numCols; c4++) { + if (totVals[c4] !== undefined) cell(tr, c4, String(totVals[c4]), true, null); + } + // Grid: outer border + column dividers + row dividers, all in one pass + // so they sit on top of all cell backgrounds. + ctx.strokeStyle = GRID; + ctx.lineWidth = 1; + ctx.strokeRect(x + 0.5, y + 0.5, totalW - 1, tableH - 1); + ctx.lineWidth = 0.75; + for (let gc = 1; gc < numCols; gc++) { + const gx = Math.round(x + colX[gc]) + 0.5; + ctx.beginPath(); + ctx.moveTo(gx, y); + ctx.lineTo(gx, y + tableH); + ctx.stroke(); + } + for (let gr = 1; gr < numRows; gr++) { + const gy = Math.round(y + gr * rowH) + 0.5; + ctx.beginPath(); + ctx.moveTo(x, gy); + ctx.lineTo(x + totalW, gy); + ctx.stroke(); + } + 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 @@ -1806,6 +2250,30 @@ function exportPNG() { octx = prevOctx; } + // Flight-plan placement on the export. Priority: + // 1. The per-session "Add plan to map" placeholder (explicit user + // intent for export placement; see createPlanPlaceholder). + // 2. The legacy pinned-plan rect (still honoured if no placeholder + // is active, so the existing pin-to-map feature is unchanged). + var planRect = null; + if (typeof planPlaceholderActive === 'function' && planPlaceholderActive()) { + planRect = planPlaceholderEl.getBoundingClientRect(); + } else if (localStorage.getItem('navaid.planPin') === '1') { + var fpBox = document.querySelector('.modal-back.flight-plan > .modal'); + if (fpBox) planRect = fpBox.getBoundingClientRect(); + } + if (planRect) { + var fpx = (planRect.left - fr.x) * s; + var fpy = (planRect.top - fr.y) * s; + var fpw = planRect.width * s; + var fph = planRect.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), + planPlacementAlign); + } + } + out.toBlob(b => { btn.textContent = btnLabel; btn.disabled = false; diff --git a/docs/style.css b/docs/style.css index 7106f098..f83ad628 100644 --- a/docs/style.css +++ b/docs/style.css @@ -824,6 +824,114 @@ html, body { flex: 1; min-height: 0; } +.modal.pinned .fp-scroll { + /* Per user spec: no scroll in pinned mode. Font auto-shrinks to fit + (computePlanFontSize uses scrH / rows × 0.85, floor 2px), and column + widths are fixed percentages — content fits the box without a bar. */ + overflow: hidden; + min-width: 0; +} +.modal.pinned { + border-color: #ffd966; + box-shadow: 0 0 0 2px rgba(255, 217, 102, 0.3); + min-width: 0; + min-height: 0; + max-width: none; + max-height: none; + padding: 2px 4px; +} +.modal.pinned .flight-table, +.modal.pinned .flight-table input { + font-size: var(--fp-font-size, 13px); + /* line-height 1 so row height tracks the font down to ~0px without a + residual padding/leading floor — rows can never overlap. */ + line-height: 1; +} +.modal.pinned .fp-aircraft, +.modal.pinned .modal-btns, +.modal.pinned .modal-title, +.modal.pinned .modal-close-x { + display: none; +} +.modal.pinned .flight-table th, +.modal.pinned .flight-table td { + /* Zero padding so character runs from one column abut the next. */ + position: static; + padding: 0; + border: none; + white-space: nowrap; + overflow: visible; + text-overflow: clip; + /* Each cell only as wide as its OWN content — without this each column + widens to the max-content across all rows (including the header), + so a long header like "Dist (NM)" left big gaps after short data + like "3.3". Setting per-row widths via auto layout is what we get + when we drop tabular-nums and let proportional widths apply. */ + font-variant-numeric: normal; +} +.modal.pinned .flight-table { border-spacing: 0; } +.modal.pinned .flight-table th { font-weight: 600; } +/* Hide the long-form header row entirely — once the user has the data + on the map they know the layout (#, From → To, Hdg, Dist, Spd, Alt, + Time, Fuel); the header was the dominant source of column slack. */ +.modal.pinned .flight-table thead { display: none; } +.modal.pinned .flight-table input { + width: auto; + max-width: 100%; + padding: 0; + border: none; + background: transparent; + border-radius: 0; + min-width: 0; + height: auto; + /* Pinned mode is a read-only on-map readout. Block direct edits but + keep programmatic .value updates from refresh() / retRefresh() — + pointer-events skips clicks, caret-color hides any stray cursor. */ + pointer-events: none; + caret-color: transparent; + user-select: none; +} +.modal.pinned .flight-table tfoot td { + border: none; + font-weight: 700; +} +.modal.pinned .flight-table { + margin-bottom: 0; + border-collapse: collapse; + /* Shrink-wrap (max-content) — table sizes to the sum of its column + widths, no slack distributed across cells. Font-shrink keeps the + natural width ≤ box width, so no max-width clamp needed. */ + table-layout: auto; + width: max-content; +} +.modal.pinned .flight-table th:nth-child(1), +.modal.pinned .flight-table td:nth-child(1) { text-align: center; } +.modal.pinned .flight-table th:nth-child(4), +.modal.pinned .flight-table td:nth-child(4) { text-align: center; } +.modal.pinned .flight-table th:nth-child(5), +.modal.pinned .flight-table td:nth-child(5) { text-align: right; } +.modal.pinned .flight-table th:nth-child(6), +.modal.pinned .flight-table td:nth-child(6) { text-align: right; } +.modal.pinned .flight-table th:nth-child(7), +.modal.pinned .flight-table td:nth-child(7) { text-align: right; } +.modal.pinned .flight-table th:nth-child(8), +.modal.pinned .flight-table td:nth-child(8) { width: 10%; text-align: center; } +.modal.pinned .flight-table th:nth-child(9), +.modal.pinned .flight-table td:nth-child(9) { width: 15%; text-align: right; } +.modal.pinned .flight-table th:nth-child(1), +.modal.pinned .flight-table td:nth-child(1) { text-align: center; } +.modal.pinned .flight-table th:nth-child(4), +.modal.pinned .flight-table td:nth-child(4) { text-align: center; } +.modal.pinned .flight-table th:nth-child(5), +.modal.pinned .flight-table td:nth-child(5) { text-align: right; } +.modal.pinned .flight-table th:nth-child(6), +.modal.pinned .flight-table td:nth-child(6) { text-align: right; } +.modal.pinned .flight-table th:nth-child(7), +.modal.pinned .flight-table td:nth-child(7) { text-align: right; } +.modal.pinned .flight-table th:nth-child(8), +.modal.pinned .flight-table td:nth-child(8) { text-align: center; } +.modal.pinned .flight-table th:nth-child(9), +.modal.pinned .flight-table td:nth-child(9) { text-align: right; } .flight-table { width: 100%; border-collapse: collapse; @@ -874,22 +982,6 @@ html, body { border-bottom: none; background: rgba(255, 255, 255, 0.03); } -.fp-del { - width: 20px; - padding: 2px 2px !important; - text-align: center; -} -.fp-del button { - background: none; - border: none; - color: #b06464; - cursor: pointer; - font-size: 14px; - line-height: 1; - padding: 0 2px; - border-radius: 3px; -} -.fp-del button:hover { color: #e06060; background: rgba(255, 80, 80, 0.1); } .flight-plan-sub { font-size: 13px; font-weight: 600; @@ -1078,6 +1170,121 @@ 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; } +/* When pinned, lift the pin button above the box so it never overlaps + table content — the box is read-only and may shrink very small. */ +.modal.pinned .modal-pin { + top: -22px; + inset-inline-end: 0; + width: 22px; + height: 22px; + font-size: 13px; + background: rgba(20, 18, 18, 0.85); +} +.modal.pinned { cursor: move; } +.modal.pinned input { cursor: text; } + +.resize-handle { + position: absolute; + width: 16px; + height: 16px; + z-index: 1; +} +.resize-handle::after { + content: ''; + position: absolute; + 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; } + +/* Per-session wireframe placeholder shown by the Export PNG dialog's "Add + plan to map" button. Marks where drawFlightPlanTable() should render on + the PNG; the placeholder itself is a plain DOM element and is NOT drawn + into the export canvas. */ +.plan-placeholder { + position: fixed; + z-index: 600; + background: rgba(42, 38, 38, 0.85); + border: 1px dashed #ffd966; + border-radius: 4px; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + cursor: move; + user-select: none; + overflow: hidden; +} +.plan-placeholder-hdr { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 6px; + background: rgba(255, 217, 102, 0.15); + border-bottom: 1px dashed rgba(255, 217, 102, 0.5); + flex: 0 0 auto; +} +.plan-placeholder-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + color: #ffd966; + text-transform: uppercase; +} +.plan-placeholder-close { + width: 20px; + height: 20px; + padding: 0; + background: transparent; + border: none; + color: #e8e8e8; + font-size: 16px; + line-height: 1; + cursor: pointer; + border-radius: 3px; +} +.plan-placeholder-close:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } +.plan-placeholder-body { + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: space-evenly; + padding: 6px 8px; + gap: 4px; + min-height: 0; +} +.plan-placeholder-bar { + flex: 1 1 0; + min-height: 4px; + max-height: 14px; + background: rgba(200, 200, 200, 0.45); + border-radius: 2px; +} + /* 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 { @@ -1172,6 +1379,15 @@ html, body { } .blink-warn { animation: blink-warn 1.2s ease-in-out infinite; } +/* Flight-plan placement picker (export modal). One button per option; + the selected one highlights in the yellow accent. */ +.modal .modal-btns + div button.active, +.modal button.active[type="button"] { + background: #ffd966; + color: #231F20; + border-color: #ffd966; +} + /* magnifying glass */ #tool-magnifier { font-size: 18px; line-height: 1; padding: 2px 6px; } #tool-magnifier.active { background: #3a3636; border-color: #ffcc33; color: #ffcc33; } diff --git a/tests/export-png-options.spec.js b/tests/export-png-options.spec.js index 778b43ba..8957a313 100644 --- a/tests/export-png-options.spec.js +++ b/tests/export-png-options.spec.js @@ -287,4 +287,66 @@ test.describe('Export PNG options modal', () => { const download = await dl; expect(download.suggestedFilename()).toMatch(/^navigation-.+\.png$/); }); + + test('Plan placement: corner pick puts a placeholder near the chosen corner', 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(); + }); + await page.locator('#print').click(); + await page.locator('.modal-back').waitFor(); + // Click the TL placement button (the buttons live in the placement + // row after the page-warning). + const placeBtn = page.locator('.modal button').filter({ hasText: /TL/ }); + await placeBtn.click(); + // A placeholder element shows up on the page. + const placeholder = page.locator('.plan-placeholder'); + await expect(placeholder).toBeVisible(); + // TL → small offsets from origin. + const box = await placeholder.boundingBox(); + expect(box).not.toBeNull(); + if (box) { + expect(box.x).toBeLessThan(200); + expect(box.y).toBeLessThan(200); + } + }); + + test('Plan placement: None removes any existing placeholder', 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(); + }); + await page.locator('#print').click(); + await page.locator('.modal-back').waitFor(); + await page.locator('.modal button').filter({ hasText: /BR/ }).click(); + await expect(page.locator('.plan-placeholder')).toBeVisible(); + await page.locator('.modal button').filter({ hasText: /None/ }).click(); + await expect(page.locator('.plan-placeholder')).toHaveCount(0); + }); + + test('Export with a placement still produces a PNG', 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(); + }); + await page.locator('#print').click(); + await page.locator('.modal-back').waitFor(); + await page.locator('.modal button').filter({ hasText: /TL/ }).click(); + const dl = page.waitForEvent('download', { timeout: 30000 }); + 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 0c94b9ee..8de0825e 100644 --- a/tests/flight-plan.spec.js +++ b/tests/flight-plan.spec.js @@ -367,6 +367,32 @@ test.describe('Flight plan', () => { } }); + test('Pin button no longer exists (UX moved to export modal)', async ({ page }) => { + await page.locator('#plan').click(); + const modal = page.locator('.modal-back.flight-plan'); + await expect(modal).toBeVisible(); + // .modal-pin and resize handles retired — placement now picked in + // the Export PNG modal. + await expect(modal.locator('.modal-pin')).toHaveCount(0); + await expect(modal.locator('.resize-handle')).toHaveCount(0); + }); + + test('Stale pin-mode localStorage keys are cleared on open', async ({ page }) => { + // Seed the old keys, open the modal, verify they're gone. + await page.evaluate(() => { + try { + localStorage.setItem('navaid.planPin', '1'); + localStorage.setItem('navaid.planW', '400'); + localStorage.setItem('navaid.planH', '250'); + } catch (e) {} + }); + await page.locator('#plan').click(); + await page.locator('.modal-back.flight-plan').waitFor(); + expect(await page.evaluate(() => localStorage.getItem('navaid.planPin'))).toBeNull(); + expect(await page.evaluate(() => localStorage.getItem('navaid.planW'))).toBeNull(); + expect(await page.evaluate(() => localStorage.getItem('navaid.planH'))).toBeNull(); + }); + test('R key reverses the route', async ({ page }) => { const firstBefore = await page.evaluate(() => state.waypoints[0].name); const lastBefore = await page.evaluate(() => state.waypoints[state.waypoints.length - 1].name); @@ -454,116 +480,4 @@ test.describe('Flight plan', () => { const leftover = await page.evaluate(() => window.__touchCount); expect(leftover).toBe(0); }); - - test('delete-leg button exists on every forward row', async ({ page }) => { - await page.locator('#plan').click(); - const modal = page.locator('.modal-back.flight-plan'); - await expect(modal).toBeVisible(); - - const fwdRows = modal.locator('.flight-table').first().locator('tbody tr'); - await expect(fwdRows).toHaveCount(10); - for (let i = 0; i < 10; i++) { - const delBtn = fwdRows.nth(i).locator('.fp-del button'); - await expect(delBtn).toBeVisible(); - } - }); - - test('delete middle leg (index 3) removes waypoint and reconnects', async ({ page }) => { - await page.locator('#plan').click(); - const modal = page.locator('.modal-back.flight-plan'); - await expect(modal).toBeVisible(); - - // Delete leg 3 (0-based) → removes waypoint 4 (SHARO), reconnects HADRA (idx 4→3 after splice) - const fwdRows = modal.locator('.flight-table').first().locator('tbody tr'); - await fwdRows.nth(3).locator('.fp-del button').click(); - - // Modal rebuilds with 9 legs - await expect(fwdRows).toHaveCount(9); - - // Leg 3 now connects SHARO(3) → FRDIS(4) (HADRA was removed) - const fromInp = fwdRows.nth(3).locator('td').nth(1).locator('input'); - const toInp = fwdRows.nth(3).locator('td').nth(2).locator('input'); - await expect(fromInp).toHaveValue('SHARO'); - await expect(toInp).toHaveValue('FRDIS'); - }); - - test('delete first leg removes waypoint 1 and reconnects', async ({ page }) => { - await page.locator('#plan').click(); - const modal = page.locator('.modal-back.flight-plan'); - await expect(modal).toBeVisible(); - - const fwdRows = modal.locator('.flight-table').first().locator('tbody tr'); - await fwdRows.nth(0).locator('.fp-del button').click(); - - await expect(fwdRows).toHaveCount(9); - // Now waypoint 0 was the first in the route — waypoint 1 (BAZRA) was removed. - // Leg 0 connects LLHZ (idx 0) → DEROR (old idx 2, now idx 1) - // Actually: delete leg 0 removes waypoint[1] = BAZRA. - // Leg 0 becomes LLHZ(0) → DEROR(1) - const fromInp = fwdRows.nth(0).locator('td').nth(1).locator('input'); - const toInp = fwdRows.nth(0).locator('td').nth(2).locator('input'); - await expect(fromInp).toHaveValue('LLHZ'); - await expect(toInp).toHaveValue('DEROR'); - }); - - test('delete last leg removes final waypoint shortens route by 1', async ({ page }) => { - await page.locator('#plan').click(); - const modal = page.locator('.modal-back.flight-plan'); - await expect(modal).toBeVisible(); - - const fwdRows = modal.locator('.flight-table').first().locator('tbody tr'); - await fwdRows.nth(9).locator('.fp-del button').click(); - - await expect(fwdRows).toHaveCount(9); - // Last leg connects GALIM → (removed LLHA). After delete: - // Leg 8: GALIM(8) → LLHA was at idx 9, removed. So now leg 8 is DAROM(7?) → GALIM(8?) - // Actually let me think. After delete leg 9: - // waypoints: [LLHZ, BAZRA, DEROR, SHARO, HADRA, FRDIS, BOREN, HOTRM, DAROM, GALIM] - // leg 8: GALIM → (removed LLHA), so leg 8 becomes DAROM → GALIM - const lastFrom = await fwdRows.nth(8).locator('td').nth(1).locator('input').inputValue(); - const lastTo = await fwdRows.nth(8).locator('td').nth(2).locator('input').inputValue(); - expect(lastFrom).toBe('DAROM'); - expect(lastTo).toBe('GALIM'); - }); - - test('delete leg updates state correctly (waypoints + legs trimmed)', async ({ page }) => { - await page.locator('#plan').click(); - const fwdRows = page.locator('.modal-back.flight-plan .flight-table').first().locator('tbody tr'); - await fwdRows.nth(5).locator('.fp-del button').click(); - - const wpLen = await page.evaluate(() => state.waypoints.length); - const legLen = await page.evaluate(() => state.legs.length); - expect(wpLen).toBe(10); // was 11, removed 1 - expect(legLen).toBe(9); // was 10, removed 1 - expect(legLen).toBe(wpLen - 1); - }); - - test('delete-leg with return route: both tables rebuild correctly', async ({ page }) => { - await page.evaluate(() => { window.showReturn = true; }); - await page.locator('#plan').click(); - const modal = page.locator('.modal-back.flight-plan'); - await expect(modal).toBeVisible(); - - const tables = modal.locator('.fp-scroll > .flight-table'); - const fwdRows = tables.first().locator('tbody tr'); - const retRows = tables.nth(1).locator('tbody tr'); - - // Delete middle leg - await fwdRows.nth(4).locator('.fp-del button').click(); - - await expect(fwdRows).toHaveCount(9); - await expect(retRows).toHaveCount(9); - - // Return headings should still be reciprocal of forward - const fwdHdgs = await fwdRows.evaluateAll(rows => - rows.map(r => parseInt((r.querySelectorAll('td')[3]?.textContent || '').replace('°M', ''), 10)) - ); - const retHdgs = await retRows.evaluateAll(rows => - rows.map(r => parseInt((r.querySelectorAll('td')[3]?.textContent || '').replace('°M', ''), 10)) - ); - for (let i = 0; i < 9; i++) { - const ri = 8 - i; - expect(retHdgs[i]).toBe((fwdHdgs[ri] + 180) % 360); - } - }); }); diff --git a/tests/fuel-flight-plan.spec.js b/tests/fuel-flight-plan.spec.js index 5c4c2689..25d5cef9 100644 --- a/tests/fuel-flight-plan.spec.js +++ b/tests/fuel-flight-plan.spec.js @@ -34,33 +34,19 @@ async function openFlightPlan(page) { } // Read (legFuel of row 0, totalFuel from tfoot) from the flight table. -// Find the fuel column by matching its header text ("Fuel (gal)" or "דלק (גאל)"). async function readFuelCells(page) { return page.evaluate(() => { const table = document.querySelector('.flight-table'); if (!table) return { legFuel: NaN, totalFuel: NaN }; - const headers = table.querySelectorAll('thead th'); - let fuelCol = -1; - for (let i = 0; i < headers.length; i++) { - if (/Fuel|דלק/.test(headers[i].textContent)) { fuelCol = i; break; } - } - if (fuelCol < 0) return { legFuel: NaN, totalFuel: NaN }; const legRow = table.querySelector('tbody tr:first-child'); const totalRow = table.querySelector('tfoot tr:first-child'); - function cellAt(row, col) { + function lastCell(row) { if (!row) return NaN; const cells = row.querySelectorAll('td'); - // tfoot first td has colspan=4, so visual col maps differently. - // Walk cells accumulating colspan until we reach the visual column. - let vis = 0; - for (const c of cells) { - const cs = c.colSpan || 1; - if (vis <= col && col < vis + cs) return parseFloat(c.textContent); - vis += cs; - } - return NaN; + const last = cells[cells.length - 1]; + return last ? parseFloat(last.textContent) : NaN; } - return { legFuel: cellAt(legRow, fuelCol), totalFuel: cellAt(totalRow, fuelCol) }; + return { legFuel: lastCell(legRow), totalFuel: lastCell(totalRow) }; }); } @@ -97,24 +83,10 @@ test.describe('Fuel/endurance flight plan modal', () => { await page.locator('#aircraft-gph').dispatchEvent('input'); const totFuel = await page.evaluate(() => { - const table = document.querySelector('.flight-table'); - if (!table) return null; - const headers = table.querySelectorAll('thead th'); - let fuelCol = -1; - for (let i = 0; i < headers.length; i++) { - if (/Fuel|דלק/.test(headers[i].textContent)) { fuelCol = i; break; } - } - if (fuelCol < 0) return null; - const totalRow = table.querySelector('tfoot tr:first-child'); + const totalRow = document.querySelector('.flight-table tfoot tr:first-child'); if (!totalRow) return null; const cells = totalRow.querySelectorAll('td'); - let vis = 0; - for (const c of cells) { - const cs = c.colSpan || 1; - if (vis <= fuelCol && fuelCol < vis + cs) return c.textContent; - vis += cs; - } - return null; + return cells[cells.length - 1] ? cells[cells.length - 1].textContent : null; }); expect(totFuel).toBe('--'); }); diff --git a/tests/orient-pageexport.spec.js b/tests/orient-pageexport.spec.js index 060529a2..33de2879 100644 --- a/tests/orient-pageexport.spec.js +++ b/tests/orient-pageexport.spec.js @@ -94,7 +94,7 @@ test.describe('PNG export filename respects pageSize + orient', () => { await page.locator('#page-a4').click(); const dl = page.waitForEvent('download', { timeout: 30000 }); await page.locator('#print').click(); - await page.locator('.modal-back button:first-child').click(); + await page.locator('.modal-back .modal-btns button:first-child').click(); const download = await dl; expect(download.suggestedFilename()).toMatch(/^navigation-A4-.+\.png$/); }); @@ -110,7 +110,7 @@ test.describe('PNG export filename respects pageSize + orient', () => { // pageSize stays null — exporter falls back to the baseName (layer-derived). const dl = page.waitForEvent('download', { timeout: 30000 }); await page.locator('#print').click(); - await page.locator('.modal-back button:first-child').click(); + await page.locator('.modal-back .modal-btns button:first-child').click(); const download = await dl; expect(download.suggestedFilename()).toMatch(/^navigation-.+-\d.+\.png$/); });