Create dashboard.ts
This commit is contained in:
parent
58bb3cb4f8
commit
28323f145e
1 changed files with 300 additions and 0 deletions
300
video/src/dashboard.ts
Normal file
300
video/src/dashboard.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue