This commit is contained in:
Andras Schmelczer 2026-05-06 22:40:46 +01:00
parent 28323f145e
commit 94f9c0d594
76 changed files with 3238 additions and 1230 deletions

View file

@ -21,6 +21,22 @@ interface HexagonSnapshot {
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;
@ -46,6 +62,7 @@ export class DashboardRecorder {
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) => {
@ -78,6 +95,9 @@ export class DashboardRecorder {
}
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');
@ -146,6 +166,11 @@ export class DashboardRecorder {
}
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;
}
@ -217,6 +242,56 @@ export class DashboardRecorder {
.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.page.locator('[data-tutorial="map"]').boundingBox();
if (!mapBox) throw new Error('Map container has no bounding box');
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 = 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.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 >= 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
);
return candidates.slice(0, limit).map(({ score: _score, ...target }) => target);
}
}
function classifyApiRequest(rawUrl: string): ApiKind | null {
@ -250,6 +325,18 @@ function parseHexagonSnapshot(rawUrl: string, body: unknown): HexagonSnapshot |
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);
@ -271,8 +358,21 @@ function isHexagonFeature(value: unknown): value is HexagonFeature {
);
}
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: HexagonFeature,
feature: { lat: number; lon: number },
bounds: Bounds,
mapBox: { x: number; y: number; width: number; height: number }
): { x: number; y: number } | null {