Improve vide

This commit is contained in:
Andras Schmelczer 2026-07-14 16:22:23 +01:00
parent e544b946c9
commit 716e42a57d
7 changed files with 358 additions and 121 deletions

View file

@ -1,5 +1,6 @@
import type { Page, Request, Response } from 'playwright';
import { waitForAnimationFrames } from './dom.js';
import type { HexPreference } from './script.js';
interface Bounds {
south: number;
@ -129,7 +130,10 @@ export class DashboardRecorder {
await this.waitForStable({ afterSelectionVersion, timeoutMs });
}
async visibleHexagonTargets(limit = 8): Promise<HexagonClickTarget[]> {
async visibleHexagonTargets(
limit = 8,
prefer?: HexPreference
): Promise<HexagonClickTarget[]> {
// Empty responses don't replace the snapshot (parse* skips them), so a
// snapshot whose bounds differ from the latest REQUEST means the current
// view has zero matching features and we'd be projecting stale data.
@ -146,7 +150,7 @@ export class DashboardRecorder {
);
}
}
const postcodeTargets = await this.visiblePostcodeTargets(limit);
const postcodeTargets = await this.visiblePostcodeTargets(limit, prefer);
if (postcodeTargets.length > 0) return postcodeTargets;
const snapshot = this.lastHexagons;
@ -175,32 +179,13 @@ export class DashboardRecorder {
y: point.y,
count: feature.count,
score: feature.count / (1 + distanceFromCenter * 0.25),
distanceFromCenter,
prefValue: prefer ? asFiniteNumber(feature[`avg_${prefer.field}`]) : null,
};
})
.filter((target): target is HexagonClickTarget & { score: number } => target != null);
.filter((target): target is ScoredTarget => 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);
return finishCandidates(projected, mapBox, clear, limit, prefer);
}
/**
@ -347,7 +332,10 @@ export class DashboardRecorder {
return !loading && !connecting;
}
private async visiblePostcodeTargets(limit: number): Promise<HexagonClickTarget[]> {
private async visiblePostcodeTargets(
limit: number,
prefer?: HexPreference
): Promise<HexagonClickTarget[]> {
const snapshot = this.lastPostcodes;
if (!snapshot || snapshot.features.length === 0) return [];
@ -373,32 +361,109 @@ export class DashboardRecorder {
y: point.y,
count: feature.properties.count,
score: feature.properties.count / (1 + distanceFromCenter * 0.25),
distanceFromCenter,
prefValue: prefer
? asFiniteNumber(feature.properties[`avg_${prefer.field}`])
: null,
};
})
.filter((target): target is HexagonClickTarget & { score: number } => target != null);
.filter((target): target is ScoredTarget => 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);
if (projected.length === 0) return [];
return finishCandidates(projected, mapBox, clear, limit, prefer);
}
}
interface ScoredTarget extends HexagonClickTarget {
score: number;
distanceFromCenter: number;
/** `avg_<field>` from the map response when a preference is in play. */
prefValue: number | null;
}
function asFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
/**
* Shared candidate ordering for hexagon/postcode targets: constrain to the
* on-screen chrome-free area, then rank. Without a preference the ranking is
* the classic crowd-and-centre score. With one, candidates are ranked by
* their `avg_<field>` value (normalised across the visible set) with a mild
* centre bias and a nudge away from near-empty polygons, so a "cheapest
* nearby" zoom lands on the green end of the coloured ramp instead of the
* busiest (often priciest) block.
*/
function finishCandidates(
projected: ScoredTarget[],
mapBox: { x: number; y: number; width: number; height: number },
clear: { top: number; bottom: number; left: number; right: number },
limit: number,
prefer?: HexPreference
): HexagonClickTarget[] {
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 pool = clearOfChrome.length > 0 ? clearOfChrome : onScreen;
if (pool.length === 0) {
throw new Error('No visible hexagons from the latest map response can be clicked');
}
const strip = ({
score: _score,
distanceFromCenter: _d,
prefValue: _v,
...target
}: ScoredTarget): HexagonClickTarget => target;
if (prefer) {
const valued = pool.filter((c) => c.prefValue != null);
// Need a real spread to rank by value; degenerate/missing data falls
// back to the crowd score rather than chasing one noisy polygon.
if (valued.length >= 3) {
const values = valued.map((c) => c.prefValue as number);
const lo = Math.min(...values);
const hi = Math.max(...values);
const span = hi - lo || 1;
const ranked = valued
.map((c) => {
const norm = ((c.prefValue as number) - lo) / span;
const directional = prefer.direction === 'min' ? norm : 1 - norm;
const penalty = 0.2 * c.distanceFromCenter + (c.count < 3 ? 0.25 : 0);
return { c, key: directional + penalty };
})
.sort((a, b) => a.key - b.key);
const best = ranked[0].c;
console.log(
`[dashboard] prefer ${prefer.direction} "${prefer.field}": picked ${best.h3} ` +
`(avg ${best.prefValue}, count ${best.count}) from ${valued.length} valued ` +
`candidates spanning ${lo.toFixed(1)}..${hi.toFixed(1)}`
);
return ranked.slice(0, limit).map(({ c }) => strip(c));
}
console.log(
`[dashboard] prefer "${prefer.field}" requested but only ${valued.length} ` +
`candidates carry avg_ values; falling back to crowd scoring`
);
}
return pool
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(strip);
}
function classifyApiRequest(rawUrl: string): ApiKind | null {
let path: string;
try {