mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
Improve mobile map popup QA
This commit is contained in:
270
e2e/mobile-map-popup.spec.ts
Normal file
270
e2e/mobile-map-popup.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { devices, expect, test } from '@playwright/test';
|
||||
|
||||
type HarnessWindow = Window & {
|
||||
__mobileMapHarness?: {
|
||||
ready: boolean;
|
||||
getPopupRect: () => {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
} | null;
|
||||
getFirstHotspotRect: () => {
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
};
|
||||
__mobileMapIntegrationHarness?: {
|
||||
ready: boolean;
|
||||
getPopupRect: () => {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
|
||||
const MOBILE_DEVICE_MATRIX = [
|
||||
{ label: 'iPhone SE', use: devices['iPhone SE'] },
|
||||
{ label: 'iPhone 14 Pro Max', use: devices['iPhone 14 Pro Max'] },
|
||||
{ label: 'Pixel 5', use: devices['Pixel 5'] },
|
||||
{ label: 'Galaxy S9+', use: devices['Galaxy S9+'] },
|
||||
] as const;
|
||||
|
||||
for (const deviceConfig of MOBILE_DEVICE_MATRIX) {
|
||||
test.describe(`Mobile SVG popup QA (${deviceConfig.label})`, () => {
|
||||
const { defaultBrowserType: _defaultBrowserType, ...contextOptions } = deviceConfig.use;
|
||||
test.use(contextOptions);
|
||||
|
||||
test('keeps popup in viewport and supports dismissal patterns', async ({ page }) => {
|
||||
const pageErrors: string[] = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
|
||||
await page.goto('/tests/mobile-map-harness.html');
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() => {
|
||||
const w = window as HarnessWindow;
|
||||
return Boolean(w.__mobileMapHarness?.ready);
|
||||
});
|
||||
}, { timeout: 20000 })
|
||||
.toBe(true);
|
||||
|
||||
const hotspotRect = await page.evaluate(() => {
|
||||
const w = window as HarnessWindow;
|
||||
return w.__mobileMapHarness?.getFirstHotspotRect() ?? null;
|
||||
});
|
||||
|
||||
expect(hotspotRect).not.toBeNull();
|
||||
expect(hotspotRect?.width ?? 0).toBeGreaterThanOrEqual(44);
|
||||
expect(hotspotRect?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
|
||||
const hotspot = page.locator('.hotspot').first();
|
||||
await expect(hotspot).toBeVisible();
|
||||
await hotspot.tap();
|
||||
|
||||
const popup = page.locator('.map-popup.map-popup-sheet');
|
||||
await expect(popup).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() => {
|
||||
const w = window as HarnessWindow;
|
||||
const rect = w.__mobileMapHarness?.getPopupRect();
|
||||
if (!rect) return false;
|
||||
return (
|
||||
rect.left >= 0 &&
|
||||
rect.top >= 0 &&
|
||||
rect.right <= rect.viewportWidth + 1 &&
|
||||
rect.bottom <= rect.viewportHeight + 1
|
||||
);
|
||||
});
|
||||
}, { timeout: 5000 })
|
||||
.toBe(true);
|
||||
|
||||
const popupRect = await page.evaluate(() => {
|
||||
const w = window as HarnessWindow;
|
||||
return w.__mobileMapHarness?.getPopupRect() ?? null;
|
||||
});
|
||||
|
||||
expect(popupRect).not.toBeNull();
|
||||
expect(popupRect?.left ?? -1).toBeGreaterThanOrEqual(0);
|
||||
expect(popupRect?.top ?? -1).toBeGreaterThanOrEqual(0);
|
||||
expect((popupRect?.right ?? 0) - (popupRect?.viewportWidth ?? 0)).toBeLessThanOrEqual(1);
|
||||
expect((popupRect?.bottom ?? 0) - (popupRect?.viewportHeight ?? 0)).toBeLessThanOrEqual(1);
|
||||
|
||||
const dragPopupBy = async (distance: number): Promise<void> => {
|
||||
await page.evaluate((dragDistance) => {
|
||||
const popupEl = document.querySelector('.map-popup.map-popup-sheet') as HTMLElement | null;
|
||||
const handle = document.querySelector('.map-popup-sheet-handle') as HTMLElement | null;
|
||||
if (!popupEl || !handle || typeof Touch === 'undefined') return;
|
||||
|
||||
const rect = handle.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const startY = rect.top + rect.height / 2;
|
||||
const endY = startY + dragDistance;
|
||||
const target = handle;
|
||||
|
||||
const makeTouch = (y: number): Touch =>
|
||||
new Touch({
|
||||
identifier: 42,
|
||||
target,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pageX: x,
|
||||
pageY: y,
|
||||
screenX: x,
|
||||
screenY: y,
|
||||
radiusX: 2,
|
||||
radiusY: 2,
|
||||
rotationAngle: 0,
|
||||
force: 0.5,
|
||||
});
|
||||
|
||||
const startTouch = makeTouch(startY);
|
||||
target.dispatchEvent(
|
||||
new TouchEvent('touchstart', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
touches: [startTouch],
|
||||
targetTouches: [startTouch],
|
||||
changedTouches: [startTouch],
|
||||
})
|
||||
);
|
||||
|
||||
const moveTouch = makeTouch(endY);
|
||||
target.dispatchEvent(
|
||||
new TouchEvent('touchmove', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
touches: [moveTouch],
|
||||
targetTouches: [moveTouch],
|
||||
changedTouches: [moveTouch],
|
||||
})
|
||||
);
|
||||
|
||||
target.dispatchEvent(
|
||||
new TouchEvent('touchend', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
touches: [],
|
||||
targetTouches: [],
|
||||
changedTouches: [moveTouch],
|
||||
})
|
||||
);
|
||||
}, distance);
|
||||
};
|
||||
|
||||
await dragPopupBy(48);
|
||||
await expect(page.locator('.map-popup.map-popup-sheet')).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() => {
|
||||
const popupEl = document.querySelector('.map-popup.map-popup-sheet') as HTMLElement | null;
|
||||
return popupEl?.style.transform ?? null;
|
||||
});
|
||||
}, { timeout: 2000 })
|
||||
.toBe('');
|
||||
|
||||
await dragPopupBy(150);
|
||||
await expect(page.locator('.map-popup')).toHaveCount(0);
|
||||
|
||||
await hotspot.tap();
|
||||
await expect(page.locator('.map-popup.map-popup-sheet')).toBeVisible();
|
||||
await page.locator('.popup-close').first().tap();
|
||||
await expect(page.locator('.map-popup')).toHaveCount(0);
|
||||
|
||||
await hotspot.tap();
|
||||
await expect(page.locator('.map-popup.map-popup-sheet')).toBeVisible();
|
||||
|
||||
await page.touchscreen.tap(6, 6);
|
||||
await expect(page.locator('.map-popup')).toHaveCount(0);
|
||||
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Mobile SVG popup integration path', () => {
|
||||
const { defaultBrowserType: _defaultBrowserType, ...iphoneSE } = devices['iPhone SE'];
|
||||
test.use(iphoneSE);
|
||||
|
||||
test('opens popup through MapComponent hotspot marker tap', async ({ page }) => {
|
||||
const pageErrors: string[] = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
|
||||
await page.goto('/tests/mobile-map-integration-harness.html');
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() => {
|
||||
const w = window as HarnessWindow;
|
||||
return Boolean(w.__mobileMapIntegrationHarness?.ready);
|
||||
});
|
||||
}, { timeout: 30000 })
|
||||
.toBe(true);
|
||||
|
||||
const timeSlider = page.locator('.time-slider');
|
||||
const mapControls = page.locator('.map-controls');
|
||||
await expect(timeSlider).toBeVisible();
|
||||
await expect(mapControls).toBeVisible();
|
||||
const controlsDoNotOverlap = await page.evaluate(() => {
|
||||
const slider = document.querySelector('.time-slider') as HTMLElement | null;
|
||||
const controls = document.querySelector('.map-controls') as HTMLElement | null;
|
||||
if (!slider || !controls) return false;
|
||||
const sliderRect = slider.getBoundingClientRect();
|
||||
const controlsRect = controls.getBoundingClientRect();
|
||||
return sliderRect.right <= controlsRect.left + 1;
|
||||
});
|
||||
expect(controlsDoNotOverlap).toBe(true);
|
||||
|
||||
const hotspot = page.locator('.hotspot').first();
|
||||
await expect(hotspot).toBeVisible();
|
||||
await hotspot.tap();
|
||||
|
||||
const popup = page.locator('.map-popup.map-popup-sheet');
|
||||
await expect(popup).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() => {
|
||||
const w = window as HarnessWindow;
|
||||
const rect = w.__mobileMapIntegrationHarness?.getPopupRect();
|
||||
if (!rect) return false;
|
||||
return (
|
||||
rect.left >= 0 &&
|
||||
rect.top >= 0 &&
|
||||
rect.right <= rect.viewportWidth + 1 &&
|
||||
rect.bottom <= rect.viewportHeight + 1
|
||||
);
|
||||
});
|
||||
}, { timeout: 5000 })
|
||||
.toBe(true);
|
||||
|
||||
const popupRect = await page.evaluate(() => {
|
||||
const w = window as HarnessWindow;
|
||||
return w.__mobileMapIntegrationHarness?.getPopupRect() ?? null;
|
||||
});
|
||||
|
||||
expect(popupRect).not.toBeNull();
|
||||
expect(popupRect?.left ?? -1).toBeGreaterThanOrEqual(0);
|
||||
expect(popupRect?.top ?? -1).toBeGreaterThanOrEqual(0);
|
||||
expect((popupRect?.right ?? 0) - (popupRect?.viewportWidth ?? 0)).toBeLessThanOrEqual(1);
|
||||
expect((popupRect?.bottom ?? 0) - (popupRect?.viewportHeight ?? 0)).toBeLessThanOrEqual(1);
|
||||
|
||||
await popup.locator('.popup-close').first().tap();
|
||||
await expect(page.locator('.map-popup')).toHaveCount(0);
|
||||
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -620,6 +620,14 @@ export class MapComponent {
|
||||
let lastPos = { x: 0, y: 0 };
|
||||
let lastTouchDist = 0;
|
||||
let lastTouchCenter = { x: 0, y: 0 };
|
||||
const shouldIgnoreInteractionStart = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return Boolean(
|
||||
target.closest(
|
||||
'.map-controls, .time-slider, .layer-toggles, .map-legend, .layer-help-popup, .map-popup, button, select, input, textarea, a'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Wheel zoom with smooth delta
|
||||
this.container.addEventListener(
|
||||
@@ -652,6 +660,7 @@ export class MapComponent {
|
||||
|
||||
// Mouse drag for panning
|
||||
this.container.addEventListener('mousedown', (e) => {
|
||||
if (shouldIgnoreInteractionStart(e.target)) return;
|
||||
if (e.button === 0) { // Left click
|
||||
isDragging = true;
|
||||
lastPos = { x: e.clientX, y: e.clientY };
|
||||
@@ -682,6 +691,7 @@ export class MapComponent {
|
||||
|
||||
// Touch events for mobile and trackpad
|
||||
this.container.addEventListener('touchstart', (e) => {
|
||||
if (shouldIgnoreInteractionStart(e.target)) return;
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
|
||||
|
||||
@@ -129,6 +129,11 @@ export class MapPopup {
|
||||
private onClose?: () => void;
|
||||
private cableAdvisories: CableAdvisory[] = [];
|
||||
private repairShips: RepairShip[] = [];
|
||||
private isMobileSheet = false;
|
||||
private sheetTouchStartY: number | null = null;
|
||||
private sheetCurrentOffset = 0;
|
||||
private readonly mobileDismissThreshold = 96;
|
||||
private outsideListenerTimeoutId: number | null = null;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
@@ -137,73 +142,24 @@ export class MapPopup {
|
||||
public show(data: PopupData): void {
|
||||
this.hide();
|
||||
|
||||
this.isMobileSheet = isMobileDevice();
|
||||
this.popup = document.createElement('div');
|
||||
this.popup.className = 'map-popup';
|
||||
this.popup.className = this.isMobileSheet ? 'map-popup map-popup-sheet' : 'map-popup';
|
||||
|
||||
const content = this.renderContent(data);
|
||||
this.popup.innerHTML = content;
|
||||
this.popup.innerHTML = this.isMobileSheet
|
||||
? `<button class="map-popup-sheet-handle" aria-label="${t('common.close')}"></button>${content}`
|
||||
: content;
|
||||
|
||||
// Get container's viewport position for absolute positioning
|
||||
const containerRect = this.container.getBoundingClientRect();
|
||||
|
||||
if (isMobileDevice()) {
|
||||
// On mobile, center the popup horizontally and position in upper area
|
||||
this.popup.style.left = '50%';
|
||||
this.popup.style.transform = 'translateX(-50%)';
|
||||
this.popup.style.top = `${Math.max(60, Math.min(containerRect.top + data.y, window.innerHeight * 0.4))}px`;
|
||||
} else {
|
||||
// Desktop: position near click with smart bounds checking
|
||||
if (this.isMobileSheet) {
|
||||
this.popup.style.left = '';
|
||||
this.popup.style.top = '';
|
||||
this.popup.style.transform = '';
|
||||
const popupWidth = 380;
|
||||
const bottomBuffer = 50; // Buffer from viewport bottom
|
||||
const topBuffer = 60; // Header height
|
||||
|
||||
// Temporarily append popup off-screen to measure actual height
|
||||
this.popup.style.visibility = 'hidden';
|
||||
this.popup.style.top = '0';
|
||||
this.popup.style.left = '-9999px';
|
||||
document.body.appendChild(this.popup);
|
||||
const popupHeight = this.popup.offsetHeight;
|
||||
document.body.removeChild(this.popup);
|
||||
this.popup.style.visibility = '';
|
||||
|
||||
// Convert container-relative coords to viewport coords
|
||||
const viewportX = containerRect.left + data.x;
|
||||
const viewportY = containerRect.top + data.y;
|
||||
|
||||
// Horizontal positioning (viewport-relative)
|
||||
const maxX = window.innerWidth - popupWidth - 20;
|
||||
let left = viewportX + 20;
|
||||
if (left > maxX) {
|
||||
// Position to the left of click if it would overflow right
|
||||
left = Math.max(10, viewportX - popupWidth - 20);
|
||||
}
|
||||
|
||||
// Vertical positioning - prefer below click, but flip above if needed
|
||||
const availableBelow = window.innerHeight - viewportY - bottomBuffer;
|
||||
const availableAbove = viewportY - topBuffer;
|
||||
|
||||
let top: number;
|
||||
if (availableBelow >= popupHeight) {
|
||||
// Enough space below - position below click
|
||||
top = viewportY + 10;
|
||||
} else if (availableAbove >= popupHeight) {
|
||||
// Not enough below, but enough above - position above click
|
||||
top = viewportY - popupHeight - 10;
|
||||
} else {
|
||||
// Limited space both ways - position at top buffer
|
||||
top = topBuffer;
|
||||
}
|
||||
|
||||
// CRITICAL: Ensure popup stays within viewport vertically
|
||||
top = Math.max(topBuffer, top);
|
||||
const maxTop = window.innerHeight - popupHeight - bottomBuffer;
|
||||
if (maxTop > topBuffer) {
|
||||
top = Math.min(top, maxTop);
|
||||
}
|
||||
|
||||
this.popup.style.left = `${left}px`;
|
||||
this.popup.style.top = `${top}px`;
|
||||
} else {
|
||||
this.positionDesktopPopup(data, containerRect);
|
||||
}
|
||||
|
||||
// Append to body to avoid container overflow clipping
|
||||
@@ -211,24 +167,160 @@ export class MapPopup {
|
||||
|
||||
// Close button handler
|
||||
this.popup.querySelector('.popup-close')?.addEventListener('click', () => this.hide());
|
||||
this.popup.querySelector('.map-popup-sheet-handle')?.addEventListener('click', () => this.hide());
|
||||
|
||||
if (this.isMobileSheet) {
|
||||
this.popup.addEventListener('touchstart', this.handleSheetTouchStart, { passive: true });
|
||||
this.popup.addEventListener('touchmove', this.handleSheetTouchMove, { passive: false });
|
||||
this.popup.addEventListener('touchend', this.handleSheetTouchEnd);
|
||||
this.popup.addEventListener('touchcancel', this.handleSheetTouchEnd);
|
||||
requestAnimationFrame(() => this.popup?.classList.add('open'));
|
||||
}
|
||||
|
||||
// Click outside to close
|
||||
setTimeout(() => {
|
||||
if (this.outsideListenerTimeoutId !== null) {
|
||||
window.clearTimeout(this.outsideListenerTimeoutId);
|
||||
}
|
||||
this.outsideListenerTimeoutId = window.setTimeout(() => {
|
||||
document.addEventListener('click', this.handleOutsideClick);
|
||||
}, 100);
|
||||
document.addEventListener('touchstart', this.handleOutsideClick);
|
||||
document.addEventListener('keydown', this.handleEscapeKey);
|
||||
this.outsideListenerTimeoutId = null;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private handleOutsideClick = (e: MouseEvent) => {
|
||||
private positionDesktopPopup(data: PopupData, containerRect: DOMRect): void {
|
||||
if (!this.popup) return;
|
||||
|
||||
const popupWidth = 380;
|
||||
const bottomBuffer = 50; // Buffer from viewport bottom
|
||||
const topBuffer = 60; // Header height
|
||||
|
||||
// Temporarily append popup off-screen to measure actual height
|
||||
this.popup.style.visibility = 'hidden';
|
||||
this.popup.style.top = '0';
|
||||
this.popup.style.left = '-9999px';
|
||||
document.body.appendChild(this.popup);
|
||||
const popupHeight = this.popup.offsetHeight;
|
||||
document.body.removeChild(this.popup);
|
||||
this.popup.style.visibility = '';
|
||||
|
||||
// Convert container-relative coords to viewport coords
|
||||
const viewportX = containerRect.left + data.x;
|
||||
const viewportY = containerRect.top + data.y;
|
||||
|
||||
// Horizontal positioning (viewport-relative)
|
||||
const maxX = window.innerWidth - popupWidth - 20;
|
||||
let left = viewportX + 20;
|
||||
if (left > maxX) {
|
||||
// Position to the left of click if it would overflow right
|
||||
left = Math.max(10, viewportX - popupWidth - 20);
|
||||
}
|
||||
|
||||
// Vertical positioning - prefer below click, but flip above if needed
|
||||
const availableBelow = window.innerHeight - viewportY - bottomBuffer;
|
||||
const availableAbove = viewportY - topBuffer;
|
||||
|
||||
let top: number;
|
||||
if (availableBelow >= popupHeight) {
|
||||
// Enough space below - position below click
|
||||
top = viewportY + 10;
|
||||
} else if (availableAbove >= popupHeight) {
|
||||
// Not enough below, but enough above - position above click
|
||||
top = viewportY - popupHeight - 10;
|
||||
} else {
|
||||
// Limited space both ways - position at top buffer
|
||||
top = topBuffer;
|
||||
}
|
||||
|
||||
// CRITICAL: Ensure popup stays within viewport vertically
|
||||
top = Math.max(topBuffer, top);
|
||||
const maxTop = window.innerHeight - popupHeight - bottomBuffer;
|
||||
if (maxTop > topBuffer) {
|
||||
top = Math.min(top, maxTop);
|
||||
}
|
||||
|
||||
this.popup.style.left = `${left}px`;
|
||||
this.popup.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
private handleOutsideClick = (e: Event) => {
|
||||
if (this.popup && !this.popup.contains(e.target as Node)) {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
private handleEscapeKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
private handleSheetTouchStart = (e: TouchEvent): void => {
|
||||
if (!this.popup || !this.isMobileSheet || e.touches.length !== 1) return;
|
||||
|
||||
const target = e.target as HTMLElement | null;
|
||||
const popupBody = this.popup.querySelector('.popup-body');
|
||||
if (target?.closest('.popup-body') && popupBody && popupBody.scrollTop > 0) {
|
||||
this.sheetTouchStartY = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.sheetTouchStartY = e.touches[0]?.clientY ?? null;
|
||||
this.sheetCurrentOffset = 0;
|
||||
this.popup.classList.add('dragging');
|
||||
};
|
||||
|
||||
private handleSheetTouchMove = (e: TouchEvent): void => {
|
||||
if (!this.popup || !this.isMobileSheet || this.sheetTouchStartY === null) return;
|
||||
|
||||
const currentY = e.touches[0]?.clientY;
|
||||
if (currentY == null) return;
|
||||
|
||||
const delta = Math.max(0, currentY - this.sheetTouchStartY);
|
||||
if (delta <= 0) return;
|
||||
|
||||
this.sheetCurrentOffset = delta;
|
||||
this.popup.style.transform = `translate3d(0, ${delta}px, 0)`;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
private handleSheetTouchEnd = (): void => {
|
||||
if (!this.popup || !this.isMobileSheet || this.sheetTouchStartY === null) return;
|
||||
|
||||
const shouldDismiss = this.sheetCurrentOffset >= this.mobileDismissThreshold;
|
||||
this.popup.classList.remove('dragging');
|
||||
this.sheetTouchStartY = null;
|
||||
|
||||
if (shouldDismiss) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
this.sheetCurrentOffset = 0;
|
||||
this.popup.style.transform = '';
|
||||
this.popup.classList.add('open');
|
||||
};
|
||||
|
||||
public hide(): void {
|
||||
if (this.outsideListenerTimeoutId !== null) {
|
||||
window.clearTimeout(this.outsideListenerTimeoutId);
|
||||
this.outsideListenerTimeoutId = null;
|
||||
}
|
||||
|
||||
if (this.popup) {
|
||||
this.popup.removeEventListener('touchstart', this.handleSheetTouchStart);
|
||||
this.popup.removeEventListener('touchmove', this.handleSheetTouchMove);
|
||||
this.popup.removeEventListener('touchend', this.handleSheetTouchEnd);
|
||||
this.popup.removeEventListener('touchcancel', this.handleSheetTouchEnd);
|
||||
this.popup.remove();
|
||||
this.popup = null;
|
||||
this.isMobileSheet = false;
|
||||
this.sheetTouchStartY = null;
|
||||
this.sheetCurrentOffset = 0;
|
||||
document.removeEventListener('click', this.handleOutsideClick);
|
||||
document.removeEventListener('touchstart', this.handleOutsideClick);
|
||||
document.removeEventListener('keydown', this.handleEscapeKey);
|
||||
this.onClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
103
src/e2e/mobile-map-harness.ts
Normal file
103
src/e2e/mobile-map-harness.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import '../styles/main.css';
|
||||
import { MapPopup } from '../components/MapPopup';
|
||||
import type { Hotspot } from '../types';
|
||||
|
||||
type MobileMapHarness = {
|
||||
ready: boolean;
|
||||
getPopupRect: () => {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
} | null;
|
||||
getFirstHotspotRect: () => {
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__mobileMapHarness?: MobileMapHarness;
|
||||
}
|
||||
}
|
||||
|
||||
const app = document.getElementById('app');
|
||||
if (!app) {
|
||||
throw new Error('Missing #app container for mobile popup harness');
|
||||
}
|
||||
|
||||
document.body.style.margin = '0';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
app.className = 'map-container';
|
||||
app.style.width = '100vw';
|
||||
app.style.height = '100vh';
|
||||
app.style.position = 'relative';
|
||||
app.style.overflow = 'hidden';
|
||||
|
||||
const overlays = document.createElement('div');
|
||||
overlays.id = 'mapOverlays';
|
||||
app.appendChild(overlays);
|
||||
|
||||
const sampleHotspot: Hotspot = {
|
||||
id: 'e2e-hotspot',
|
||||
name: 'E2E Hotspot',
|
||||
lat: 33.0,
|
||||
lon: 36.0,
|
||||
keywords: ['e2e', 'hotspot'],
|
||||
level: 'high',
|
||||
location: 'E2E Zone',
|
||||
description: 'Deterministic hotspot used for mobile popup QA.',
|
||||
agencies: ['E2E Agency'],
|
||||
status: 'monitoring',
|
||||
};
|
||||
|
||||
const popup = new MapPopup(app);
|
||||
|
||||
const hotspot = document.createElement('div');
|
||||
hotspot.className = 'hotspot';
|
||||
hotspot.style.left = '50%';
|
||||
hotspot.style.top = '50%';
|
||||
hotspot.innerHTML = '<div class="hotspot-marker high"></div>';
|
||||
hotspot.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const rect = app.getBoundingClientRect();
|
||||
popup.show({
|
||||
type: 'hotspot',
|
||||
data: sampleHotspot,
|
||||
relatedNews: [],
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
});
|
||||
});
|
||||
overlays.appendChild(hotspot);
|
||||
|
||||
window.__mobileMapHarness = {
|
||||
ready: true,
|
||||
getPopupRect: () => {
|
||||
const element = document.querySelector('.map-popup') as HTMLElement | null;
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
};
|
||||
},
|
||||
getFirstHotspotRect: () => {
|
||||
const firstHotspot = document.querySelector('.hotspot') as HTMLElement | null;
|
||||
if (!firstHotspot) return null;
|
||||
const rect = firstHotspot.getBoundingClientRect();
|
||||
return { width: rect.width, height: rect.height };
|
||||
},
|
||||
};
|
||||
213
src/e2e/mobile-map-integration-harness.ts
Normal file
213
src/e2e/mobile-map-integration-harness.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import '../styles/main.css';
|
||||
import { MapComponent } from '../components/Map';
|
||||
import { initI18n } from '../services/i18n';
|
||||
|
||||
type MobileMapIntegrationHarness = {
|
||||
ready: boolean;
|
||||
getPopupRect: () => {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__mobileMapIntegrationHarness?: MobileMapIntegrationHarness;
|
||||
}
|
||||
}
|
||||
|
||||
const app = document.getElementById('app');
|
||||
if (!app) {
|
||||
throw new Error('Missing #app container for mobile map integration harness');
|
||||
}
|
||||
|
||||
document.body.style.margin = '0';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
app.className = 'map-container';
|
||||
app.style.width = '100vw';
|
||||
app.style.height = '100vh';
|
||||
app.style.position = 'relative';
|
||||
app.style.overflow = 'hidden';
|
||||
|
||||
const MINIMAL_WORLD_TOPOLOGY = {
|
||||
type: 'Topology',
|
||||
objects: {
|
||||
countries: {
|
||||
type: 'GeometryCollection',
|
||||
geometries: [
|
||||
{
|
||||
type: 'Polygon',
|
||||
id: 1,
|
||||
arcs: [[0]],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
arcs: [
|
||||
[
|
||||
[0, 0],
|
||||
[3600, 0],
|
||||
[0, 1800],
|
||||
[-3600, 0],
|
||||
[0, -1800],
|
||||
],
|
||||
],
|
||||
transform: {
|
||||
scale: [0.1, 0.1],
|
||||
translate: [-180, -90],
|
||||
},
|
||||
};
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
|
||||
if (url.includes('world-atlas@2/countries-50m.json')) {
|
||||
return new Response(JSON.stringify(MINIMAL_WORLD_TOPOLOGY), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
return originalFetch(input, init);
|
||||
}) as typeof fetch;
|
||||
|
||||
const layers = {
|
||||
conflicts: false,
|
||||
bases: false,
|
||||
cables: false,
|
||||
pipelines: false,
|
||||
hotspots: true,
|
||||
ais: false,
|
||||
nuclear: false,
|
||||
irradiators: false,
|
||||
sanctions: false,
|
||||
weather: false,
|
||||
economic: false,
|
||||
waterways: false,
|
||||
outages: false,
|
||||
cyberThreats: false,
|
||||
datacenters: false,
|
||||
protests: false,
|
||||
flights: false,
|
||||
military: false,
|
||||
natural: false,
|
||||
spaceports: false,
|
||||
minerals: false,
|
||||
fires: false,
|
||||
ucdpEvents: false,
|
||||
displacement: false,
|
||||
climate: false,
|
||||
startupHubs: false,
|
||||
cloudRegions: false,
|
||||
accelerators: false,
|
||||
techHQs: false,
|
||||
techEvents: false,
|
||||
stockExchanges: false,
|
||||
financialCenters: false,
|
||||
centralBanks: false,
|
||||
commodityHubs: false,
|
||||
gulfInvestments: false,
|
||||
};
|
||||
|
||||
await initI18n();
|
||||
|
||||
const map = new MapComponent(app, {
|
||||
zoom: 2.7,
|
||||
pan: { x: 0, y: 0 },
|
||||
view: 'global',
|
||||
layers,
|
||||
timeRange: 'all',
|
||||
});
|
||||
|
||||
let ready = false;
|
||||
let fallbackInjected = false;
|
||||
const ensureHotspotsRendered = (): void => {
|
||||
if (document.querySelector('.hotspot')) {
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for deterministic tests if the async world fetch is delayed.
|
||||
if (!fallbackInjected) {
|
||||
const mapInternals = map as unknown as {
|
||||
worldData: unknown;
|
||||
countryFeatures: unknown;
|
||||
baseRendered: boolean;
|
||||
hotspots: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
keywords: string[];
|
||||
level: 'low' | 'elevated' | 'high';
|
||||
description: string;
|
||||
status: string;
|
||||
}>;
|
||||
state: { layers: { hotspots: boolean } };
|
||||
};
|
||||
mapInternals.worldData = MINIMAL_WORLD_TOPOLOGY;
|
||||
mapInternals.countryFeatures = [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'E2E Country' },
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [[[-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90]]],
|
||||
},
|
||||
},
|
||||
];
|
||||
mapInternals.hotspots = [
|
||||
{
|
||||
id: 'e2e-map-hotspot',
|
||||
name: 'E2E Map Hotspot',
|
||||
lat: 20,
|
||||
lon: 10,
|
||||
keywords: ['e2e', 'integration'],
|
||||
level: 'high',
|
||||
description: 'Integration harness hotspot',
|
||||
status: 'monitoring',
|
||||
},
|
||||
];
|
||||
mapInternals.state.layers.hotspots = true;
|
||||
mapInternals.baseRendered = false;
|
||||
map.render();
|
||||
fallbackInjected = true;
|
||||
}
|
||||
|
||||
requestAnimationFrame(ensureHotspotsRendered);
|
||||
};
|
||||
ensureHotspotsRendered();
|
||||
|
||||
window.__mobileMapIntegrationHarness = {
|
||||
get ready() {
|
||||
return ready;
|
||||
},
|
||||
getPopupRect: () => {
|
||||
const element = document.querySelector('.map-popup') as HTMLElement | null;
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -4534,6 +4534,57 @@ a.prediction-link:hover {
|
||||
box-shadow: 0 4px 24px rgba(255, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.map-popup.map-popup-sheet {
|
||||
left: 12px !important;
|
||||
right: 12px !important;
|
||||
top: auto !important;
|
||||
bottom: 0;
|
||||
width: auto !important;
|
||||
max-width: none;
|
||||
max-height: min(68vh, calc(100vh - 80px));
|
||||
border-bottom: none;
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -12px 32px rgba(0, 0, 0, 0.35);
|
||||
transform: translate3d(0, 110%, 0);
|
||||
transition: transform 0.22s ease-out;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.map-popup.map-popup-sheet.open {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.map-popup.map-popup-sheet.dragging {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.map-popup-sheet-handle {
|
||||
display: block;
|
||||
width: 56px;
|
||||
height: 24px;
|
||||
margin: 6px auto 2px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.map-popup-sheet-handle::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
margin: 10px auto 0;
|
||||
border-radius: 999px;
|
||||
background: var(--text-dim);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4630,6 +4681,10 @@ a.prediction-link:hover {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
line-height: 1;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.popup-close:hover {
|
||||
@@ -4640,6 +4695,10 @@ a.prediction-link:hover {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.map-popup.map-popup-sheet .popup-body {
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.popup-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--green);
|
||||
@@ -7481,20 +7540,9 @@ a.prediction-link:hover {
|
||||
/* Expand touch targets for all map markers using ::before pseudo-element */
|
||||
/* This creates an invisible touch area around small markers */
|
||||
|
||||
.hotspot::before,
|
||||
.base-marker::before,
|
||||
.earthquake-marker::before,
|
||||
.nuclear-marker::before,
|
||||
.economic-marker::before,
|
||||
.datacenter-marker::before,
|
||||
.protest-marker::before,
|
||||
.flight-marker::before,
|
||||
.outage-marker::before,
|
||||
.weather-marker::before,
|
||||
.military-flight-marker::before,
|
||||
.military-vessel-marker::before,
|
||||
.spaceport-marker::before,
|
||||
.mineral-marker::before {
|
||||
#mapOverlays [class*='-marker']::before,
|
||||
#mapOverlays .hotspot::before,
|
||||
#mapOverlays .conflict-click-area::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -7505,9 +7553,15 @@ a.prediction-link:hover {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
border-radius: 50%;
|
||||
pointer-events: auto;
|
||||
/* Uncomment to debug: background: rgba(255, 0, 0, 0.2); */
|
||||
}
|
||||
|
||||
.conflict-click-area {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Ensure hotspot container is large enough for touch */
|
||||
.hotspot {
|
||||
min-width: 44px;
|
||||
@@ -7593,14 +7647,37 @@ a.prediction-link:hover {
|
||||
}
|
||||
|
||||
/* Simplify time slider on mobile */
|
||||
.map-controls {
|
||||
top: calc(env(safe-area-inset-top, 0px) + 8px);
|
||||
right: calc(env(safe-area-inset-right, 0px) + 8px);
|
||||
}
|
||||
|
||||
.time-slider {
|
||||
top: calc(env(safe-area-inset-top, 0px) + 8px);
|
||||
left: calc(env(safe-area-inset-left, 0px) + 8px);
|
||||
right: calc(env(safe-area-inset-right, 0px) + 56px);
|
||||
max-width: none;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding: 4px 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.time-slider::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.time-slider-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.time-slider-buttons {
|
||||
flex-wrap: nowrap;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
/* Larger map controls */
|
||||
.map-control-btn {
|
||||
width: 44px;
|
||||
@@ -7610,6 +7687,7 @@ a.prediction-link:hover {
|
||||
|
||||
/* Time slider buttons */
|
||||
.time-btn {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 12px;
|
||||
min-height: 36px;
|
||||
}
|
||||
@@ -7621,6 +7699,17 @@ a.prediction-link:hover {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.map-popup.map-popup-sheet {
|
||||
left: 10px !important;
|
||||
right: 10px !important;
|
||||
max-height: min(72vh, calc(100vh - 64px));
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Hide some UI elements that clutter mobile view */
|
||||
.map-timestamp {
|
||||
font-size: 9px;
|
||||
@@ -7704,6 +7793,11 @@ a.prediction-link:hover {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.map-popup.map-popup-sheet {
|
||||
left: 8px !important;
|
||||
right: 8px !important;
|
||||
}
|
||||
|
||||
/* Stack header on very small screens */
|
||||
.header {
|
||||
flex-wrap: wrap;
|
||||
@@ -13820,4 +13914,4 @@ body.has-critical-banner .panels-grid {
|
||||
|
||||
.cw-dismiss:hover {
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
}
|
||||
|
||||
12
tests/mobile-map-harness.html
Normal file
12
tests/mobile-map-harness.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mobile SVG Map Harness</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/e2e/mobile-map-harness.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
12
tests/mobile-map-integration-harness.html
Normal file
12
tests/mobile-map-integration-harness.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mobile SVG Map Integration Harness</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/e2e/mobile-map-integration-harness.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user