perfect-postcode/video/src/dashboard.ts
2026-05-26 19:45:13 +01:00

454 lines
14 KiB
TypeScript

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<Request>();
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<void> {
await this.waitForStable({ afterMapVersion, timeoutMs });
}
async waitForSelectionReady(afterSelectionVersion: number, timeoutMs = 12000): Promise<void> {
await this.page
.locator(SELECTION_PANE_SELECTOR)
.first()
.waitFor({ state: 'visible', timeout: timeoutMs });
await this.waitForStable({ afterSelectionVersion, timeoutMs });
}
async visibleHexagonTargets(limit = 8): Promise<HexagonClickTarget[]> {
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<void> {
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<void> {
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<boolean> {
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<HexagonClickTarget[]> {
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<string, unknown> {
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);
}