import type { Page, Request, Response } from 'playwright'; import { waitForAnimationFrames } from './dom.js'; import type { HexPreference } from './script.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; private lastRequestedMapBounds: string | null = null; constructor(private readonly page: Page) { page.on('request', (request) => { const kind = classifyApiRequest(request.url()); if (kind) this.pending.add(request); if (kind === 'hexagons' || kind === 'postcodes') { try { this.lastRequestedMapBounds = new URL(request.url()).searchParams.get('bounds'); } catch { /* ignore */ } } }); 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 }); } /** * Best-effort wait for tracked API traffic to go quiet (250ms of no * pending requests and no loading indicator). Unlike waitForStable this * never throws: it simply returns at the deadline. Used before computing * hexagon click targets so the projection runs against the response for * the CURRENT viewport rather than one captured mid-animation. */ async waitForApiIdle(timeoutMs = 3000): Promise { const deadline = Date.now() + timeoutMs; let stableSince: number | null = null; while (Date.now() < deadline) { const idle = this.pending.size === 0 && (await this.loadingIndicatorsHidden()); if (idle) { stableSince ??= Date.now(); if (Date.now() - stableSince >= 250) return; } else { stableSince = null; } await this.page.waitForTimeout(100); } } 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, prefer?: HexPreference ): Promise { // Empty responses don't replace the snapshot (parse* skips them), so a // snapshot whose bounds differ from the latest REQUEST means the current // view has zero matching features and we'd be projecting stale data. // Surface that loudly: it almost always means the storyboard's filters // emptied the area it zoomed into. const snapshotBounds = this.lastPostcodes?.bounds ?? this.lastHexagons?.bounds; if (snapshotBounds && this.lastRequestedMapBounds) { const snapKey = `${snapshotBounds.south},${snapshotBounds.west},${snapshotBounds.north},${snapshotBounds.east}`; if (snapKey !== this.lastRequestedMapBounds) { console.log( `[dashboard] WARNING: map snapshot is stale (snapshot bounds ${snapKey} ` + `vs latest request ${this.lastRequestedMapBounds}). The current view ` + `likely has no matching features; clicks may land on empty map.` ); } } const postcodeTargets = await this.visiblePostcodeTargets(limit, prefer); 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), distanceFromCenter, prefValue: prefer ? asFiniteNumber(feature[`avg_${prefer.field}`]) : null, }; }) .filter((target): target is ScoredTarget => target != null); return finishCandidates(projected, mapBox, clear, limit, prefer); } /** * 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; // Short timeout: on desktop the MobileBottomSheet doesn't exist, and a // bare boundingBox() would block for Playwright's default 30s before // the catch fires (this masqueraded as a "renderer freeze" in every // desktop take that used hex() targets). const sheet = await this.page .locator('section[class*="rounded-t-2xl"]') .first() .boundingBox({ timeout: 250 }) .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, prefer?: HexPreference ): 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), distanceFromCenter, prefValue: prefer ? asFiniteNumber(feature.properties[`avg_${prefer.field}`]) : null, }; }) .filter((target): target is ScoredTarget => target != null); if (projected.length === 0) return []; return finishCandidates(projected, mapBox, clear, limit, prefer); } } interface ScoredTarget extends HexagonClickTarget { score: number; distanceFromCenter: number; /** `avg_` from the map response when a preference is in play. */ prefValue: number | null; } function asFiniteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } /** * Shared candidate ordering for hexagon/postcode targets: constrain to the * on-screen chrome-free area, then rank. Without a preference the ranking is * the classic crowd-and-centre score. With one, candidates are ranked by * their `avg_` value (normalised across the visible set) with a mild * centre bias and a nudge away from near-empty polygons, so a "cheapest * nearby" zoom lands on the green end of the coloured ramp instead of the * busiest (often priciest) block. */ function finishCandidates( projected: ScoredTarget[], mapBox: { x: number; y: number; width: number; height: number }, clear: { top: number; bottom: number; left: number; right: number }, limit: number, prefer?: HexPreference ): HexagonClickTarget[] { 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 pool = clearOfChrome.length > 0 ? clearOfChrome : onScreen; if (pool.length === 0) { throw new Error('No visible hexagons from the latest map response can be clicked'); } const strip = ({ score: _score, distanceFromCenter: _d, prefValue: _v, ...target }: ScoredTarget): HexagonClickTarget => target; if (prefer) { const valued = pool.filter((c) => c.prefValue != null); // Need a real spread to rank by value; degenerate/missing data falls // back to the crowd score rather than chasing one noisy polygon. if (valued.length >= 3) { const values = valued.map((c) => c.prefValue as number); const lo = Math.min(...values); const hi = Math.max(...values); const span = hi - lo || 1; const ranked = valued .map((c) => { const norm = ((c.prefValue as number) - lo) / span; const directional = prefer.direction === 'min' ? norm : 1 - norm; const penalty = 0.2 * c.distanceFromCenter + (c.count < 3 ? 0.25 : 0); return { c, key: directional + penalty }; }) .sort((a, b) => a.key - b.key); const best = ranked[0].c; console.log( `[dashboard] prefer ${prefer.direction} "${prefer.field}": picked ${best.h3} ` + `(avg ${best.prefValue}, count ${best.count}) from ${valued.length} valued ` + `candidates spanning ${lo.toFixed(1)}..${hi.toFixed(1)}` ); return ranked.slice(0, limit).map(({ c }) => strip(c)); } console.log( `[dashboard] prefer "${prefer.field}" requested but only ${valued.length} ` + `candidates carry avg_ values; falling back to crowd scoring` ); } return pool .sort((a, b) => b.score - a.score) .slice(0, limit) .map(strip); } 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); }