Improve vide
This commit is contained in:
parent
e544b946c9
commit
716e42a57d
7 changed files with 358 additions and 121 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue