Create dashboard.ts

This commit is contained in:
Andras Schmelczer 2026-05-06 19:54:59 +01:00
parent 58bb3cb4f8
commit 28323f145e

300
video/src/dashboard.ts Normal file
View file

@ -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<Request>();
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<void> {
await this.waitForStable({ afterMapVersion, timeoutMs });
}
async waitForSelectionReady(afterSelectionVersion: number, timeoutMs = 12000): Promise<void> {
await this.page
.locator('[data-tutorial="right-pane"]')
.waitFor({ state: 'visible', timeout: timeoutMs });
await this.waitForStable({ afterSelectionVersion, timeoutMs });
}
async visibleHexagonTargets(limit = 8): Promise<HexagonClickTarget[]> {
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<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') {
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;
}
}
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<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 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);
}