Improve mobile map popup QA

This commit is contained in:
Elie Habib
2026-02-18 23:31:44 +04:00
parent 265a4b7bc7
commit 00e5e7299a
8 changed files with 883 additions and 77 deletions

View 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([]);
});
});

View File

@@ -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];

View File

@@ -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?.();
}
}

View 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 };
},
};

View 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,
};
},
};

View File

@@ -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);
}
}

View 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>

View 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>