import type { Page, Request, Response } from 'playwright'; import { waitForAnimationFrames } from './dom.js'; interface Bounds { south: number; west: number; north: number; east: number; } interface HexagonFeature { h3: string; count: number; lat: number; lon: number; [key: string]: unknown; } interface HexagonSnapshot { features: HexagonFeature[]; bounds: Bounds; } interface PostcodeFeature { type?: string; properties: { postcode: string; count: number; centroid: [number, number]; [key: string]: unknown; }; [key: string]: unknown; } interface PostcodeSnapshot { features: PostcodeFeature[]; bounds: Bounds; } export interface HexagonClickTarget { h3: string; x: number; y: number; count: number; } type ApiKind = 'hexagons' | 'postcodes' | 'selection-stats' | 'tracked-api'; const SELECTION_PANE_SELECTOR = '[data-tutorial="right-pane"], .fixed.inset-0.z-50:has(button[aria-label="Close drawer"])'; const TRACKED_API_PATHS = new Set([ '/api/ai-filters', '/api/export', '/api/filter-counts', '/api/hexagon-stats', '/api/hexagons', '/api/postcode-stats', '/api/postcodes', '/api/travel-destinations', ]); export class DashboardRecorder { private pending = new Set(); private mapDataVersion = 0; private selectionStatsVersion = 0; private lastHexagons: HexagonSnapshot | null = null; private lastPostcodes: PostcodeSnapshot | null = null; constructor(private readonly page: Page) { page.on('request', (request) => { if (classifyApiRequest(request.url())) this.pending.add(request); }); page.on('requestfinished', (request) => this.pending.delete(request)); page.on('requestfailed', (request) => this.pending.delete(request)); page.on('response', (response) => { void this.captureResponse(response); }); } getMapDataVersion(): number { return this.mapDataVersion; } getSelectionStatsVersion(): number { return this.selectionStatsVersion; } async waitForMapSettled(afterMapVersion: number, timeoutMs = 12000): Promise { await this.waitForStable({ afterMapVersion, timeoutMs }); } async waitForSelectionReady(afterSelectionVersion: number, timeoutMs = 12000): Promise { await this.page .locator(SELECTION_PANE_SELECTOR) .first() .waitFor({ state: 'visible', timeout: timeoutMs }); await this.waitForStable({ afterSelectionVersion, timeoutMs }); } async visibleHexagonTargets(limit = 8): Promise { const postcodeTargets = await this.visiblePostcodeTargets(limit); if (postcodeTargets.length > 0) return postcodeTargets; const snapshot = this.lastHexagons; if (!snapshot || snapshot.features.length === 0) { throw new Error('No recorded hexagon response is available for map clicking'); } const mapBox = await this.mapBoundingBox(); if (!mapBox) throw new Error('Map container has no bounding box'); const clear = await this.clickableBox(mapBox); const projected = snapshot.features .filter((feature) => feature.count > 0) .map((feature) => { const point = projectFromBounds(feature, snapshot.bounds, mapBox); if (!point) return null; const centerX = clear.left + (clear.right - clear.left) / 2; const centerY = clear.top + (clear.bottom - clear.top) / 2; const distanceFromCenter = Math.hypot( (point.x - centerX) / Math.max(1, (clear.right - clear.left) / 2), (point.y - centerY) / Math.max(1, (clear.bottom - clear.top) / 2) ); return { h3: feature.h3, x: point.x, y: point.y, count: feature.count, score: feature.count / (1 + distanceFromCenter * 0.25), }; }) .filter((target): target is HexagonClickTarget & { score: number } => target != null); const onScreen = projected.filter( (target) => target.x >= mapBox.x && target.x <= mapBox.x + mapBox.width && target.y >= mapBox.y && target.y <= mapBox.y + mapBox.height ); const clearOfChrome = onScreen.filter( (target) => target.x >= clear.left && target.x <= clear.right && target.y >= clear.top && target.y <= clear.bottom ); const candidates = (clearOfChrome.length > 0 ? clearOfChrome : onScreen).sort( (a, b) => b.score - a.score ); if (candidates.length === 0) { throw new Error('No visible hexagons from the latest map response can be clicked'); } return candidates.slice(0, limit).map(({ score: _score, ...target }) => target); } /** * Locate the map container's bounding box. DesktopMapPage tags it with * `[data-tutorial="map"]`, but MobileMapPage does not, so on mobile we * fall back to the maplibre-gl canvas element. The canvas is always * present once the map has loaded (we already wait for `canvas` in * prepareTimeline). The first canvas in the document IS the map. */ private async mapBoundingBox(): Promise< { x: number; y: number; width: number; height: number } | null > { const desktopAnchor = this.page.locator('[data-tutorial="map"]').first(); if ((await desktopAnchor.count()) > 0) { return desktopAnchor.boundingBox(); } return this.page.locator('canvas').first().boundingBox(); } /** * The pixel rect inside `mapBox` that's safe to click — i.e. not under * the dashboard's left filters pane, right details pane, or (on mobile) * the floating MobileBottomSheet. We detect the sheet via the only * `section.rounded-t-2xl` in the DOM and treat its top as a hard * bottom-clear limit; without that, hex() would happily return a * polygon hidden under the sheet on 9x16 cuts. */ private async clickableBox(mapBox: { x: number; y: number; width: number; height: number; }): Promise<{ top: number; bottom: number; left: number; right: number }> { let bottomClear = mapBox.y + mapBox.height - 115; const sheet = await this.page .locator('section[class*="rounded-t-2xl"]') .first() .boundingBox() .catch(() => null); if (sheet && sheet.y > mapBox.y && sheet.y < bottomClear) { bottomClear = sheet.y - 16; } return { top: mapBox.y + 105, bottom: bottomClear, left: mapBox.x + 80, right: mapBox.x + mapBox.width - 130, }; } private async captureResponse(response: Response): Promise { const kind = classifyApiRequest(response.url()); if (!kind || !response.ok()) return; if (kind === 'hexagons') { const body = await response.json().catch(() => null); const snapshot = parseHexagonSnapshot(response.url(), body); if (snapshot) { this.lastHexagons = snapshot; this.mapDataVersion += 1; } return; } if (kind === 'postcodes') { const body = await response.json().catch(() => null); const snapshot = parsePostcodeSnapshot(response.url(), body); if (snapshot) { this.lastPostcodes = snapshot; } this.mapDataVersion += 1; return; } if (kind === 'selection-stats') { this.selectionStatsVersion += 1; } } private async waitForStable({ afterMapVersion, afterSelectionVersion, timeoutMs, }: { afterMapVersion?: number; afterSelectionVersion?: number; timeoutMs: number; }): Promise { const deadline = Date.now() + timeoutMs; let stableSince: number | null = null; let lastReason = 'not checked yet'; while (Date.now() < deadline) { const hasMapData = afterMapVersion === undefined || this.mapDataVersion > afterMapVersion; const hasSelectionStats = afterSelectionVersion === undefined || this.selectionStatsVersion > afterSelectionVersion; const apiIdle = this.pending.size === 0; const loadingHidden = await this.loadingIndicatorsHidden(); if (hasMapData && hasSelectionStats && apiIdle && loadingHidden) { stableSince ??= Date.now(); if (Date.now() - stableSince >= 250) { await this.page.waitForTimeout(350); await waitForAnimationFrames(this.page, 4); return; } } else { stableSince = null; lastReason = [ hasMapData ? null : `map version ${this.mapDataVersion} <= ${afterMapVersion}`, hasSelectionStats ? null : `selection version ${this.selectionStatsVersion} <= ${afterSelectionVersion}`, apiIdle ? null : `${this.pending.size} tracked API request(s) pending`, loadingHidden ? null : 'dashboard loading indicator visible', ] .filter(Boolean) .join(', '); } await this.page.waitForTimeout(100); } throw new Error(`Dashboard did not settle within ${timeoutMs}ms: ${lastReason}`); } private async loadingIndicatorsHidden(): Promise { const loading = await this.page .getByText('Loading...', { exact: true }) .first() .isVisible() .catch(() => false); const connecting = await this.page .getByText('Connecting to server...', { exact: true }) .first() .isVisible() .catch(() => false); return !loading && !connecting; } private async visiblePostcodeTargets(limit: number): Promise { const snapshot = this.lastPostcodes; if (!snapshot || snapshot.features.length === 0) return []; const mapBox = await this.mapBoundingBox(); if (!mapBox) throw new Error('Map container has no bounding box'); const clear = await this.clickableBox(mapBox); const projected = snapshot.features .filter((feature) => feature.properties.count > 0) .map((feature) => { const [lon, lat] = feature.properties.centroid; const point = projectFromBounds({ lat, lon }, snapshot.bounds, mapBox); if (!point) return null; const centerX = clear.left + (clear.right - clear.left) / 2; const centerY = clear.top + (clear.bottom - clear.top) / 2; const distanceFromCenter = Math.hypot( (point.x - centerX) / Math.max(1, (clear.right - clear.left) / 2), (point.y - centerY) / Math.max(1, (clear.bottom - clear.top) / 2) ); return { h3: feature.properties.postcode, x: point.x, y: point.y, count: feature.properties.count, score: feature.properties.count / (1 + distanceFromCenter * 0.25), }; }) .filter((target): target is HexagonClickTarget & { score: number } => target != null); const onScreen = projected.filter( (target) => target.x >= mapBox.x && target.x <= mapBox.x + mapBox.width && target.y >= mapBox.y && target.y <= mapBox.y + mapBox.height ); const clearOfChrome = onScreen.filter( (target) => target.x >= clear.left && target.x <= clear.right && target.y >= clear.top && target.y <= clear.bottom ); const candidates = (clearOfChrome.length > 0 ? clearOfChrome : onScreen).sort( (a, b) => b.score - a.score ); return candidates.slice(0, limit).map(({ score: _score, ...target }) => target); } } function classifyApiRequest(rawUrl: string): ApiKind | null { let path: string; try { path = new URL(rawUrl).pathname; } catch { return null; } if (path === '/api/hexagons') return 'hexagons'; if (path === '/api/postcodes') return 'postcodes'; if (path === '/api/hexagon-stats' || path === '/api/postcode-stats') { return 'selection-stats'; } if (TRACKED_API_PATHS.has(path) || path.startsWith('/api/postcode/')) { return 'tracked-api'; } return null; } function parseHexagonSnapshot(rawUrl: string, body: unknown): HexagonSnapshot | null { if (!isRecord(body) || !Array.isArray(body.features)) return null; const features = body.features.filter(isHexagonFeature); if (features.length === 0) return null; const url = new URL(rawUrl); const bounds = parseBounds(url.searchParams.get('bounds')); if (!bounds) return null; return { features, bounds }; } function parsePostcodeSnapshot(rawUrl: string, body: unknown): PostcodeSnapshot | null { if (!isRecord(body) || !Array.isArray(body.features)) return null; const features = body.features.filter(isPostcodeFeature); if (features.length === 0) return null; const url = new URL(rawUrl); const bounds = parseBounds(url.searchParams.get('bounds')); if (!bounds) return null; return { features, bounds }; } function parseBounds(value: string | null): Bounds | null { if (!value) return null; const [south, west, north, east] = value.split(',').map(Number); if (![south, west, north, east].every(Number.isFinite)) return null; return { south, west, north, east }; } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } function isHexagonFeature(value: unknown): value is HexagonFeature { if (!isRecord(value)) return false; return ( typeof value.h3 === 'string' && typeof value.count === 'number' && typeof value.lat === 'number' && typeof value.lon === 'number' ); } function isPostcodeFeature(value: unknown): value is PostcodeFeature { if (!isRecord(value) || !isRecord(value.properties)) return false; const { postcode, count, centroid } = value.properties; return ( typeof postcode === 'string' && typeof count === 'number' && Array.isArray(centroid) && centroid.length === 2 && typeof centroid[0] === 'number' && typeof centroid[1] === 'number' ); } function projectFromBounds( feature: { lat: number; lon: number }, bounds: Bounds, mapBox: { x: number; y: number; width: number; height: number } ): { x: number; y: number } | null { const west = lonToMercatorX(bounds.west); const east = lonToMercatorX(bounds.east); const north = latToMercatorY(bounds.north); const south = latToMercatorY(bounds.south); const spanX = east - west; const spanY = south - north; if (spanX <= 0 || spanY <= 0) return null; const x = mapBox.x + ((lonToMercatorX(feature.lon) - west) / spanX) * mapBox.width; const y = mapBox.y + ((latToMercatorY(feature.lat) - north) / spanY) * mapBox.height; if (!Number.isFinite(x) || !Number.isFinite(y)) return null; return { x, y }; } function lonToMercatorX(lon: number): number { return (lon + 180) / 360; } function latToMercatorY(lat: number): number { const sin = Math.min(Math.max(Math.sin((lat * Math.PI) / 180), -0.9999), 0.9999); return 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI); }