diff --git a/video/src/dashboard.ts b/video/src/dashboard.ts new file mode 100644 index 0000000..359a365 --- /dev/null +++ b/video/src/dashboard.ts @@ -0,0 +1,300 @@ +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; +} + +export interface HexagonClickTarget { + h3: string; + x: number; + y: number; + count: number; +} + +type ApiKind = 'hexagons' | 'postcodes' | 'selection-stats' | 'tracked-api'; + +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; + + 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('[data-tutorial="right-pane"]') + .waitFor({ state: 'visible', timeout: timeoutMs }); + await this.waitForStable({ afterSelectionVersion, timeoutMs }); + } + + async visibleHexagonTargets(limit = 8): Promise { + 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.page.locator('[data-tutorial="map"]').boundingBox(); + if (!mapBox) throw new Error('Map container has no bounding box'); + + const projected = snapshot.features + .filter((feature) => feature.count > 0) + .map((feature) => { + const point = projectFromBounds(feature, snapshot.bounds, mapBox); + if (!point) return null; + const centerX = mapBox.x + mapBox.width / 2; + const centerY = mapBox.y + mapBox.height / 2; + const distanceFromCenter = Math.hypot( + (point.x - centerX) / (mapBox.width / 2), + (point.y - centerY) / (mapBox.height / 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 >= mapBox.x + 80 && + target.x <= mapBox.x + mapBox.width - 130 && + target.y >= mapBox.y + 105 && + target.y <= mapBox.y + mapBox.height - 115 + ); + + 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); + } + + 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') { + 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; + } +} + +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 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 projectFromBounds( + feature: HexagonFeature, + 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); +}