mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(cctv): seven camera packs, semantic labels, terrain-clear monitor planes (#433)
Seven contributed CCTV camera packs (Ontario 511, Fintraffic Finland, DriveBC, TxDOT Texas, Estonia Tallinn + Tarktee, Warendorf, Live Traffic NSW) on a fair per-pack catalog cap; semantic camera labels; monitor planes lifted clear of the terrain from a precomputed, bundled ground-height dataset. Default catalog 800 → 3,446 cameras. Full context in PR #433. Co-authored-by: sendmebits <[email protected]> Co-authored-by: avocadoAgent <[email protected]> Co-authored-by: Alex Harvey <[email protected]> Co-authored-by: Jason Meredith <[email protected]> Co-authored-by: Daniel Smith <[email protected]> Co-authored-by: Vladimir Sobolev <[email protected]> Co-authored-by: Matthias Schneider <[email protected]> Co-authored-by: Cyber Knight <[email protected]> Co-authored-by: NovaSoft Australia <[email protected]>
This commit is contained in:
51 files changed
+78682
-939
No files matched your search
+122
-47
@@ -31,6 +31,13 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
'austin transportation & public works': 5 * 60 * 1000,
|
||||
'transport for london': 3 * 60 * 1000,
|
||||
caltrans: 3 * 60 * 1000,
|
||||
'ontario 511': 3 * 60 * 1000,
|
||||
// TxDOT publishes roughly once a minute; 3 min matches the other highway packs.
|
||||
txdot: 3 * 60 * 1000,
|
||||
// Digitraffic publishes a new weathercam frame on each station's
|
||||
// collectionInterval, 600 s for every station sampled; polling faster only
|
||||
// re-fetches the same JPEG.
|
||||
fintraffic: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -42,7 +49,9 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
* @returns {{cardLimit:number}}
|
||||
*/
|
||||
export function cctvLodBudgets(cameraHeightM) {
|
||||
const height = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : CITY_HEIGHT_M;
|
||||
const height = Number.isFinite(cameraHeightM)
|
||||
? Math.max(0, cameraHeightM)
|
||||
: CITY_HEIGHT_M;
|
||||
if (height <= STREET_HEIGHT_M) {
|
||||
return { cardLimit: CCTV_AMBIENT_CARD_MIN };
|
||||
}
|
||||
@@ -68,7 +77,11 @@ export const CCTV_CARD_INCUMBENT_FACTOR = 0.8;
|
||||
* @param {number} [factor]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function incumbentRankKm(distanceKm, isIncumbent, factor = CCTV_CARD_INCUMBENT_FACTOR) {
|
||||
export function incumbentRankKm(
|
||||
distanceKm,
|
||||
isIncumbent,
|
||||
factor = CCTV_CARD_INCUMBENT_FACTOR,
|
||||
) {
|
||||
const km = Number.isFinite(distanceKm) ? Math.max(0, distanceKm) : Infinity;
|
||||
return isIncumbent ? km * factor : km;
|
||||
}
|
||||
@@ -81,8 +94,9 @@ export const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
|
||||
|
||||
/** True only when both viewport dimensions are finite positive pixels. */
|
||||
export function hasFiniteCctvViewport(viewW, viewH) {
|
||||
return Number.isFinite(viewW) && viewW > 0
|
||||
&& Number.isFinite(viewH) && viewH > 0;
|
||||
return (
|
||||
Number.isFinite(viewW) && viewW > 0 && Number.isFinite(viewH) && viewH > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,7 +131,10 @@ export function cctvCandidateSpreadKm(
|
||||
.filter((distanceKm) => Number.isFinite(distanceKm) && distanceKm >= 0)
|
||||
.sort((a, b) => a - b);
|
||||
if (!finite.length) return 0;
|
||||
const p = Math.min(1, Math.max(0, Number.isFinite(percentile) ? percentile : 0));
|
||||
const p = Math.min(
|
||||
1,
|
||||
Math.max(0, Number.isFinite(percentile) ? percentile : 0),
|
||||
);
|
||||
return finite[Math.round(p * (finite.length - 1))];
|
||||
}
|
||||
|
||||
@@ -176,32 +193,47 @@ export const CCTV_CARD_GRID_ROWS = 4;
|
||||
* @returns {string[]} Winner ids in priority order (cell winners, then
|
||||
* global-rank fill).
|
||||
*/
|
||||
export function distributeCctvCards(candidates, {
|
||||
budget = CCTV_AMBIENT_CARD_MAX,
|
||||
viewW = 0,
|
||||
viewH = 0,
|
||||
cols = CCTV_CARD_GRID_COLS,
|
||||
rows = CCTV_CARD_GRID_ROWS,
|
||||
} = {}) {
|
||||
export function distributeCctvCards(
|
||||
candidates,
|
||||
{
|
||||
budget = CCTV_AMBIENT_CARD_MAX,
|
||||
viewW = 0,
|
||||
viewH = 0,
|
||||
cols = CCTV_CARD_GRID_COLS,
|
||||
rows = CCTV_CARD_GRID_ROWS,
|
||||
} = {},
|
||||
) {
|
||||
const cap = Number.isFinite(budget) ? Math.max(0, Math.floor(budget)) : 0;
|
||||
const valid = (Array.isArray(candidates) ? candidates : [])
|
||||
.filter((c) => c && typeof c.id === 'string' && c.id
|
||||
&& Number.isFinite(c.sx) && Number.isFinite(c.sy));
|
||||
const valid = (Array.isArray(candidates) ? candidates : []).filter(
|
||||
(c) =>
|
||||
c &&
|
||||
typeof c.id === 'string' &&
|
||||
c.id &&
|
||||
Number.isFinite(c.sx) &&
|
||||
Number.isFinite(c.sy),
|
||||
);
|
||||
if (!cap || !valid.length) return [];
|
||||
const validViewport = hasFiniteCctvViewport(viewW, viewH);
|
||||
const width = validViewport ? viewW : 1;
|
||||
const height = validViewport ? viewH : 1;
|
||||
const nCols = Math.max(1, Math.floor(cols));
|
||||
const nRows = Math.max(1, Math.floor(rows));
|
||||
const byRank = (a, b) => (Number.isFinite(a.rankKm) ? a.rankKm : Infinity)
|
||||
- (Number.isFinite(b.rankKm) ? b.rankKm : Infinity)
|
||||
|| a.id.localeCompare(b.id);
|
||||
const byRank = (a, b) =>
|
||||
(Number.isFinite(a.rankKm) ? a.rankKm : Infinity) -
|
||||
(Number.isFinite(b.rankKm) ? b.rankKm : Infinity) ||
|
||||
a.id.localeCompare(b.id);
|
||||
|
||||
/** @type {Map<number, Array>} occupied cell -> its candidates */
|
||||
const cells = new Map();
|
||||
for (const candidate of valid) {
|
||||
const col = Math.min(nCols - 1, Math.max(0, Math.floor((candidate.sx / width) * nCols)));
|
||||
const row = Math.min(nRows - 1, Math.max(0, Math.floor((candidate.sy / height) * nRows)));
|
||||
const col = Math.min(
|
||||
nCols - 1,
|
||||
Math.max(0, Math.floor((candidate.sx / width) * nCols)),
|
||||
);
|
||||
const row = Math.min(
|
||||
nRows - 1,
|
||||
Math.max(0, Math.floor((candidate.sy / height) * nRows)),
|
||||
);
|
||||
const key = row * nCols + col;
|
||||
const bucket = cells.get(key);
|
||||
if (bucket) bucket.push(candidate);
|
||||
@@ -249,29 +281,42 @@ export function distributeCctvCards(candidates, {
|
||||
* @param {number} [options.viewH] - Viewport height (CSS px).
|
||||
* @returns {{cardIds:string[],budgets:{cardLimit:number}}}
|
||||
*/
|
||||
export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW, viewH } = {}) {
|
||||
export function selectCctvLod(
|
||||
candidates,
|
||||
{ cameraHeightM, incumbentIds, viewW, viewH } = {},
|
||||
) {
|
||||
const budgets = cctvLodBudgets(cameraHeightM);
|
||||
const incumbents = incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []);
|
||||
const incumbents =
|
||||
incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []);
|
||||
const screened = hasFiniteCctvViewport(viewW, viewH);
|
||||
if (!screened) {
|
||||
// Preserve the pre-refinement nearest-first path byte-for-behavior,
|
||||
// including its acceptance of any non-empty string ID. The stricter
|
||||
// valid-viewport rules below must not leak into this compatibility branch.
|
||||
const inView = (Array.isArray(candidates) ? candidates : [])
|
||||
.filter((candidate) => candidate && typeof candidate.id === 'string' && candidate.id)
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate && typeof candidate.id === 'string' && candidate.id,
|
||||
)
|
||||
.map((candidate) => ({
|
||||
id: candidate.id,
|
||||
distanceKm: Number.isFinite(candidate.distanceKm) ? Math.max(0, candidate.distanceKm) : Infinity,
|
||||
rankKm: incumbentRankKm(candidate.distanceKm, incumbents.has(candidate.id)),
|
||||
distanceKm: Number.isFinite(candidate.distanceKm)
|
||||
? Math.max(0, candidate.distanceKm)
|
||||
: Infinity,
|
||||
rankKm: incumbentRankKm(
|
||||
candidate.distanceKm,
|
||||
incumbents.has(candidate.id),
|
||||
),
|
||||
inView: candidate.inView === true,
|
||||
isVideo: candidate.isVideo === true,
|
||||
}))
|
||||
.filter((candidate) => candidate.inView)
|
||||
.sort((a, b) => (
|
||||
a.rankKm - b.rankKm
|
||||
|| a.distanceKm - b.distanceKm
|
||||
|| a.id.localeCompare(b.id)
|
||||
));
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.rankKm - b.rankKm ||
|
||||
a.distanceKm - b.distanceKm ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
const cardIds = [];
|
||||
const seen = new Set();
|
||||
for (const candidate of inView) {
|
||||
@@ -284,9 +329,15 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
}
|
||||
const eligibleById = new Map();
|
||||
for (const candidate of Array.isArray(candidates) ? candidates : []) {
|
||||
if (!candidate || typeof candidate.id !== 'string' || !candidate.id.trim()
|
||||
|| candidate.inView !== true || candidate.isVideo === true
|
||||
|| !Number.isFinite(candidate.distanceKm)) continue;
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate.id !== 'string' ||
|
||||
!candidate.id.trim() ||
|
||||
candidate.inView !== true ||
|
||||
candidate.isVideo === true ||
|
||||
!Number.isFinite(candidate.distanceKm)
|
||||
)
|
||||
continue;
|
||||
const normalized = {
|
||||
id: candidate.id,
|
||||
distanceKm: Math.max(0, candidate.distanceKm),
|
||||
@@ -295,7 +346,14 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
sy: Number.isFinite(candidate.sy) ? candidate.sy : NaN,
|
||||
};
|
||||
const current = eligibleById.get(normalized.id);
|
||||
if (!current || compareCctvRepresentative(normalized, current, { screened, viewW, viewH }) < 0) {
|
||||
if (
|
||||
!current ||
|
||||
compareCctvRepresentative(normalized, current, {
|
||||
screened,
|
||||
viewW,
|
||||
viewH,
|
||||
}) < 0
|
||||
) {
|
||||
eligibleById.set(normalized.id, normalized);
|
||||
}
|
||||
}
|
||||
@@ -304,7 +362,9 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
// robust distance scale or consume a bounded ambient-card slot.
|
||||
const stills = [...eligibleById.values()];
|
||||
|
||||
const spreadKm = cctvCandidateSpreadKm(stills.map((candidate) => candidate.distanceKm));
|
||||
const spreadKm = cctvCandidateSpreadKm(
|
||||
stills.map((candidate) => candidate.distanceKm),
|
||||
);
|
||||
for (const candidate of stills) {
|
||||
const blendedKm = blendCenterRankKm(
|
||||
candidate.distanceKm,
|
||||
@@ -313,17 +373,22 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
);
|
||||
candidate.rankKm = incumbentRankKm(blendedKm, incumbents.has(candidate.id));
|
||||
}
|
||||
stills.sort((a, b) => (
|
||||
a.rankKm - b.rankKm
|
||||
|| a.distanceKm - b.distanceKm
|
||||
|| a.id.localeCompare(b.id)
|
||||
));
|
||||
stills.sort(
|
||||
(a, b) =>
|
||||
a.rankKm - b.rankKm ||
|
||||
a.distanceKm - b.distanceKm ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
|
||||
let cardIds;
|
||||
// Item C: screen-distributed fill. Candidates lacking screen anchors are
|
||||
// dropped by the distribution pass; top up from the ranked pool
|
||||
// (defensive — cctv.js always projects anchors for in-view candidates).
|
||||
cardIds = distributeCctvCards(stills, { budget: budgets.cardLimit, viewW, viewH });
|
||||
cardIds = distributeCctvCards(stills, {
|
||||
budget: budgets.cardLimit,
|
||||
viewW,
|
||||
viewH,
|
||||
});
|
||||
if (cardIds.length < budgets.cardLimit) {
|
||||
const chosen = new Set(cardIds);
|
||||
for (const candidate of stills) {
|
||||
@@ -336,10 +401,12 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
}
|
||||
|
||||
function compareCctvRepresentative(a, b, { screened, viewW, viewH }) {
|
||||
if (a.distanceKm !== b.distanceKm) return a.distanceKm < b.distanceKm ? -1 : 1;
|
||||
if (a.distanceKm !== b.distanceKm)
|
||||
return a.distanceKm < b.distanceKm ? -1 : 1;
|
||||
if (screened) {
|
||||
const centerDelta = screenCenterFraction(a.sx, a.sy, viewW, viewH)
|
||||
- screenCenterFraction(b.sx, b.sy, viewW, viewH);
|
||||
const centerDelta =
|
||||
screenCenterFraction(a.sx, a.sy, viewW, viewH) -
|
||||
screenCenterFraction(b.sx, b.sy, viewW, viewH);
|
||||
if (centerDelta) return centerDelta;
|
||||
}
|
||||
const ax = Number.isFinite(a.sx) ? a.sx : Infinity;
|
||||
@@ -414,7 +481,10 @@ export function applyEvictionGrace({
|
||||
// Under cap pressure the grace-period cards go first, oldest-in-grace
|
||||
// first (they have had the longest chance to return); ties break on more
|
||||
// misses, then id for determinism.
|
||||
graced.sort((a, b) => a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id));
|
||||
graced.sort(
|
||||
(a, b) =>
|
||||
a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id),
|
||||
);
|
||||
const capacity = Math.max(0, cardLimit - keepIds.length);
|
||||
const overflow = Math.max(0, graced.length - capacity);
|
||||
for (let i = 0; i < graced.length; i++) {
|
||||
@@ -423,7 +493,10 @@ export function applyEvictionGrace({
|
||||
continue;
|
||||
}
|
||||
keepIds.push(graced[i].id);
|
||||
nextGrace.set(graced[i].id, { misses: graced[i].misses, since: graced[i].since });
|
||||
nextGrace.set(graced[i].id, {
|
||||
misses: graced[i].misses,
|
||||
since: graced[i].since,
|
||||
});
|
||||
}
|
||||
|
||||
return { keepIds, evictIds, graceState: nextGrace };
|
||||
@@ -442,6 +515,8 @@ export function staticFrameRefreshMs(camera) {
|
||||
if (Number.isFinite(explicit) && explicit > 0) {
|
||||
return Math.max(60_000, Math.min(20 * 60 * 1000, Math.round(explicit)));
|
||||
}
|
||||
const provider = String(camera?.provider || '').trim().toLowerCase();
|
||||
const provider = String(camera?.provider || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return PROVIDER_STATIC_REFRESH_MS[provider] || DEFAULT_STATIC_REFRESH_MS;
|
||||
}
|
||||
Reference in new issue
Block a user