Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 67 additions & 2 deletions docs/app/interact.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -1048,14 +1080,27 @@ 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 + ' - ' : '';
const captionText = name +
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');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand Down
6 changes: 6 additions & 0 deletions docs/app/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 10 additions & 9 deletions tests/pwa.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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$/);
});

Expand Down
21 changes: 21 additions & 0 deletions tests/ui-deep-coverage.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading