diff --git a/docs/app/interact.js b/docs/app/interact.js index 0d918866..001c166c 100644 --- a/docs/app/interact.js +++ b/docs/app/interact.js @@ -906,6 +906,29 @@ function satelliteTilePoint(lat, lng, z) { return { x, y, n }; } +// Rotate a static satellite preview's tile layer to match the main map's +// bearing — same visual orientation as the live (leaflet-rotate) modal map. +function applySatelliteSnippetRotation(snippet) { + if (!snippet) return; + const tiles = snippet.querySelector('.satellite-snippet-tiles'); + if (!tiles) return; + const b = (typeof map !== 'undefined' && map.getBearing) ? map.getBearing() : 0; + tiles.style.transform = 'rotate(' + b + 'deg)'; +} +// One-time hook: keep any visible inspector preview aligned as the main map +// rotates (e.g. via the dial or the satellite modal's two-way sync). +let _satSnippetRotateHooked = false; +function hookSatelliteSnippetRotation() { + if (_satSnippetRotateHooked || typeof map === 'undefined' || !map.on) return; + _satSnippetRotateHooked = true; + const update = () => { + document.querySelectorAll('.satellite-snippet:not(.satellite-expanded)') + .forEach(applySatelliteSnippetRotation); + }; + map.on('rotate', update); + map.on('rotateend', update); +} + function buildSatelliteSnippet(point, opts = {}) { const lat = Number(point && point.lat); const lng = Number(point && point.lng); @@ -924,6 +947,11 @@ function buildSatelliteSnippet(point, opts = {}) { snippet.dataset.zoom = String(z); snippet.style.setProperty('--sat-width', width + 'px'); snippet.style.setProperty('--sat-height', height + 'px'); + // Tiles live in their own layer so the preview can rotate to match the main + // map's bearing while the crosshair / attribution stay upright. The 3×3 grid + // overscans the visible box, so rotation never reveals corner gaps. + const tiles = document.createElement('div'); + tiles.className = 'satellite-snippet-tiles'; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const tileX = ((centerTileX + dx) % p.n + p.n) % p.n; @@ -935,9 +963,13 @@ function buildSatelliteSnippet(point, opts = {}) { img.src = satelliteTileUrl(z, tileX, tileY); img.style.left = ((centerTileX + dx) * SATELLITE_TILE_SIZE - globalX + width / 2) + 'px'; img.style.top = ((centerTileY + dy) * SATELLITE_TILE_SIZE - globalY + height / 2) + 'px'; - snippet.appendChild(img); + tiles.appendChild(img); } } + snippet.appendChild(tiles); + // Static preview rotates to the main map's bearing (expanded view is a live + // Leaflet map and handles its own rotation). + if (!expanded) applySatelliteSnippetRotation(snippet); const cross = document.createElement('span'); cross.className = 'satellite-crosshair'; snippet.appendChild(cross); @@ -1048,6 +1080,11 @@ function showSatellitePreviewModal(point, label) { // map instance, its zoomend listener, the cloned tile layers, and Leaflet's // internal window hooks (they keep referencing the detached modal DOM). let lmap = null; + // Bearing is kept in sync both ways with the main map. _syncingBearing + // guards against the set→event→set feedback loop. mainRotateHandler is + // detached on close so the closed modal's map isn't poked by later rotations. + let _syncingBearing = false; + let mainRotateHandler = null; // The title bar shows the location name + coordinates (replacing the generic // "Satellite view" header) so the point identity sits at the top, not below. const name = label ? label + ' - ' : ''; @@ -1055,7 +1092,15 @@ function showSatellitePreviewModal(point, label) { fmtLatLng(point.lat, 'N', 'S') + ' ' + fmtLatLng(point.lng, 'E', 'W'); const modal = createDraggableModal(captionText, 'modal satellite-preview-modal', - () => { if (lmap) { lmap.remove(); lmap = null; } }); + () => { + if (mainRotateHandler && typeof map !== 'undefined' && map.off) { + map.off('rotate', mainRotateHandler); + map.off('rotateend', mainRotateHandler); + mainRotateHandler = null; + } + if (lmap) { lmap.remove(); lmap = null; } + if (typeof window !== 'undefined') window.__satModalMap = null; + }); const body = document.createElement('div'); body.className = 'satellite-preview-body'; const mapEl = document.createElement('div'); @@ -1086,6 +1131,25 @@ function showSatellitePreviewModal(point, label) { // Black-on-white zoom buttons, bottom-right — identical to the main map. L.control.zoom({ position: 'bottomright' }).addTo(lmap); lmap.addControl(satelliteRotateControl(lmap)); + // Two-way bearing sync: rotating either map rotates the other. + if (lmap.setBearing && typeof map !== 'undefined' && map.setBearing) { + const syncToMain = () => { + if (_syncingBearing) return; + _syncingBearing = true; + try { map.setBearing(lmap.getBearing()); } finally { _syncingBearing = false; } + }; + lmap.on('rotate', syncToMain); + lmap.on('rotateend', syncToMain); + mainRotateHandler = () => { + if (_syncingBearing || !lmap) return; + _syncingBearing = true; + try { lmap.setBearing(map.getBearing()); } finally { _syncingBearing = false; } + }; + map.on('rotate', mainRotateHandler); + map.on('rotateend', mainRotateHandler); + } + // Test hook: expose the modal map (mirrors window.__commChangeRingsDrawn etc.) + if (typeof window !== 'undefined') window.__satModalMap = lmap; // Layer picker as a dropdown, matching the main app's view-menu selector // (#layer-select) instead of Leaflet's radio list. const layerNames = Object.keys(mLayers); @@ -1155,6 +1219,7 @@ function showSatellitePreviewModal(point, label) { function appendSatelliteSnippet(body, point, label) { const snippet = buildSatelliteSnippet(point); if (!snippet) return; + hookSatelliteSnippetRotation(); const section = document.createElement('div'); section.className = 'satellite-snippet-section'; const head = document.createElement('div'); diff --git a/docs/app/style.css b/docs/app/style.css index ccb94c84..7f96b0ac 100644 --- a/docs/app/style.css +++ b/docs/app/style.css @@ -1639,6 +1639,12 @@ body.theme-light .route-library-row { border-color: #c6ccd6; } outline: 2px solid #1d6fe0; outline-offset: 2px; } +.satellite-snippet-tiles { + position: absolute; + inset: 0; + transform-origin: center center; + will-change: transform; +} .satellite-snippet img { position: absolute; width: 256px; diff --git a/tests/pwa.spec.js b/tests/pwa.spec.js index a2cea899..b22406db 100644 --- a/tests/pwa.spec.js +++ b/tests/pwa.spec.js @@ -66,15 +66,16 @@ test.describe('Service worker', () => { test('Page registers the service worker on load', async ({ page }) => { await page.goto('?lang=en'); - await page.waitForFunction( - async () => (await navigator.serviceWorker.getRegistration()) != null, - null, - { timeout: 10000 }, - ); - const url = await page.evaluate(async () => - (await navigator.serviceWorker.getRegistration()).active - ? (await navigator.serviceWorker.getRegistration()).active.scriptURL - : (await navigator.serviceWorker.getRegistration()).installing.scriptURL); + // Poll until a worker slot (active / waiting / installing) exposes a + // scriptURL. waitForFunction retries until the value is truthy, so this + // never races a transient state where the registration exists but no + // worker reference is populated yet (the old separate evaluate did). + const handle = await page.waitForFunction(async () => { + const reg = await navigator.serviceWorker.getRegistration(); + const worker = reg && (reg.active || reg.waiting || reg.installing); + return worker ? worker.scriptURL : null; + }, null, { timeout: 15000 }); + const url = await handle.jsonValue(); expect(url).toMatch(/\/sw\.js$/); }); diff --git a/tests/ui-deep-coverage.spec.js b/tests/ui-deep-coverage.spec.js index 003ecd03..f55db686 100644 --- a/tests/ui-deep-coverage.spec.js +++ b/tests/ui-deep-coverage.spec.js @@ -93,6 +93,16 @@ test.describe('Inspector panel', () => { const snippet = page.locator('#insp-body .satellite-snippet').first(); await expect(page.locator('#insp-body .satellite-snippet-section')).toBeVisible(); + + // The static preview tiles rotate to match the main map's bearing. + await page.evaluate(() => map.setBearing(90)); + await page.evaluate(() => { state.selected = { type: 'wp', index: 0 }; showInspector(); }); + const t = await page.locator('#insp-body .satellite-snippet-tiles').first() + .evaluate(el => getComputedStyle(el).transform); + // bearing 90 → rotate(90deg) → matrix(0,1,-1,0,0,0) + expect(t).toMatch(/matrix\(\s*-?0?\.?0*\s*,\s*1\b/); + await page.evaluate(() => map.setBearing(0)); + await expect(snippet).toBeVisible(); await expect(snippet.locator('img')).toHaveCount(9); await expect(snippet.locator('.satellite-crosshair')).toBeVisible(); @@ -133,6 +143,17 @@ test.describe('Inspector panel', () => { await expect(lmap.locator('.leaflet-tile').first()).toBeVisible(); await expect(modal.getByRole('button', { name: /recentre/i })).toBeVisible(); + // Two-way bearing sync: rotating the main map rotates the modal map… + await page.evaluate(() => map.setBearing(40)); + const modalBearing = await page.evaluate(() => + window.__satModalMap ? Math.round(window.__satModalMap.getBearing()) : null); + expect(modalBearing).toBe(40); + // …and rotating the modal map rotates the main map. + await page.evaluate(() => window.__satModalMap.setBearing(120)); + const mainBearing = await page.evaluate(() => Math.round(map.getBearing())); + expect(mainBearing).toBe(120); + await page.evaluate(() => map.setBearing(0)); + // Closing destroys the Leaflet map (no leaked map instance / container), // and re-opening builds a fresh one without error. await modal.locator('.modal-close-x, [aria-label="Close"]').first().click();