fix(mapillary): port viguessr random-point + per-city delta strategy

Replaces adaptive shrink/grow bbox loop with viguessr's proven approach:
random point inside city bbox → fixed-size sub-bbox of side 2*delta → re-roll
position on empty/error. Small fixed bbox keeps Mapillary query cost under
their 'reduce data' cap; dart-throw eventually lands on a pano-rich window.

- src/lib/game.js: add per-city mapillaryDelta (HN=0.003, others=0.005)
  and export cityDeltas alongside existing cityBboxes
- src/lib/mapillary.js: rewrite fetchMapillaryImages(bbox, delta) with
  10-attempt position-only retry; revert to thumb_original_url; limit=3
- src/app/api/new-game/route.js: pass cityDeltas[cityCode] to the fetcher

Source: https://github.com/luuvanduc1999/viguessr (assets/js/game.js)
This commit is contained in:
tiennm99 committed 2026-05-10 01:38:48 +07:00
1 parent a3a0667774
commit f12fef855d
5 files changed
+346 -82

No files matched your search

@@ -0,0 +1,176 @@
---
phase: 1
title: "Per-city Delta + Random-Point Fetch"
status: pending
priority: P1
effort: "30m"
dependencies: []
---
# Phase 1: Per-city Delta + Random-Point Fetch
## Overview
Replace the adaptive bbox loop in `src/lib/mapillary.js` with viguessr's random-point dart-throw using a per-city fixed `delta`. Add `mapillaryDelta` to each `CITIES` entry. Update `new-game` route to pass delta and use `thumb_original_url`.
## Related Code Files
- Modify: `src/lib/game.js` — add `mapillaryDelta` field on each `CITIES` entry; export `cityDeltas`
- Rewrite: `src/lib/mapillary.js` — random-point + fixed-delta + retry-position
- Modify: `src/app/api/new-game/route.js` — import `cityDeltas`, pass to fetch, switch back to `thumb_original_url`
## Implementation Steps
### 1. `src/lib/game.js`
Inside each `CITIES.<code>` object, add a numeric field `mapillaryDelta`:
```js
HN: { ..., mapillaryDelta: 0.003 },
TPHCM: { ..., mapillaryDelta: 0.005 },
DL: { ..., mapillaryDelta: 0.005 },
DH: { ..., mapillaryDelta: 0.005 },
DN: { ..., mapillaryDelta: 0.005 }, // disabled but populated
```
Below the existing `cityBboxes` export, add:
```js
export const cityDeltas = Object.fromEntries(
Object.values(CITIES).map(city => [city.code, city.mapillaryDelta])
);
```
### 2. `src/lib/mapillary.js` (full rewrite)
```js
// Mapillary API utilities
//
// Strategy ported from viguessr (https://github.com/luuvanduc1999/viguessr):
// pick a random point inside the city bbox, query a small fixed-size sub-bbox
// (side = 2*delta) centered on that point, and re-roll the point on empty
// results or transient 5xx. The fixed small size keeps Mapillary query cost
// below their cap; the dart-throw eventually lands on a pano-rich window.
const MAX_RETRIES = 10;
export async function fetchMapillaryImages(bbox, delta) {
const accessToken = process.env.MAPILLARY_ACCESS_TOKEN;
if (!accessToken) {
throw new Error('MAPILLARY_ACCESS_TOKEN environment variable is not set');
}
const [minLng, minLat, maxLng, maxLat] = bbox;
let lastError = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const lat = Math.random() * (maxLat - minLat) + minLat;
const lng = Math.random() * (maxLng - minLng) + minLng;
const queryBbox = [
(lng - delta).toFixed(4),
(lat - delta).toFixed(4),
(lng + delta).toFixed(4),
(lat + delta).toFixed(4),
].join(',');
const apiUrl = `https://graph.mapillary.com/images?access_token=${accessToken}&fields=id,thumb_original_url,geometry,is_pano&limit=3&bbox=${queryBbox}&is_pano=true`;
try {
const response = await fetch(apiUrl, {
headers: { Accept: 'application/json' },
next: { revalidate: 0 },
});
if (response.status === 401) {
throw new Error('Mapillary authentication failed');
}
if (!response.ok) {
const body = await response.text().catch(() => '<unreadable>');
lastError = `${response.status}: ${body.slice(0, 200)}`;
console.error(`Attempt ${attempt}/${MAX_RETRIES}: Mapillary ${response.status}; re-rolling`);
continue;
}
const data = await response.json();
const images = data.data || [];
const panos = images.filter(img => img.is_pano);
if (panos.length > 0) {
console.log(`Found ${panos.length} panos on attempt ${attempt}`);
return { success: true, data: panos };
}
console.log(`Attempt ${attempt}/${MAX_RETRIES}: no panos; re-rolling`);
} catch (error) {
if (error.message === 'Mapillary authentication failed') {
throw error;
}
lastError = error.message;
console.error(`Attempt ${attempt}/${MAX_RETRIES} threw: ${error.message}; re-rolling`);
}
}
return {
success: false,
error: lastError
? `No panos after ${MAX_RETRIES} attempts (last: ${lastError})`
: 'No panos found',
};
}
```
Removed:
- `MAX_BBOX_AREA` / `MIN_BBOX_AREA` / `GROW_FACTOR` / `SHRINK_FACTOR`
- `getRandomSubBbox` helper (replaced inline)
- Adaptive shrink/grow loop
### 3. `src/app/api/new-game/route.js`
Update imports + call:
```js
// Before:
import { cityNames, cityBboxes } from '../../../lib/game.js';
const bbox = cityBboxes[cityCode];
const imageResult = await fetchMapillaryImages(bbox);
const imageUrl = selectedImage.thumb_2048_url;
// After:
import { cityNames, cityBboxes, cityDeltas } from '../../../lib/game.js';
const bbox = cityBboxes[cityCode];
const delta = cityDeltas[cityCode];
const imageResult = await fetchMapillaryImages(bbox, delta);
const imageUrl = selectedImage.thumb_original_url;
```
Add a guard for missing delta:
```js
if (!delta) {
return NextResponse.json({
success: false,
error: `No Mapillary delta configured for city: ${cityCode}`,
}, { status: 400 });
}
```
## Success Criteria
- [ ] `npm run build` passes
- [ ] HN: 3 sample requests → ≥2 success
- [ ] TPHCM: 3 sample requests → ≥2 success
- [ ] DL: 3 sample requests → ≥2 success
- [ ] DH: 3 sample requests → ≥2 success
- [ ] No 'reduce data' 500s in production logs after deploy
- [ ] `grep "MAX_BBOX_AREA\|GROW_FACTOR\|SHRINK_FACTOR" src/` empty
## Risk Assessment
- **Risk**: viguessr's HN delta=0.003 (666m) might still occasionally hit 'reduce data' if Mapillary tightens further. **Mitigation**: 10 retries; if persistent, drop HN delta to 0.002.
- **Risk**: Per-city delta requires a value for each enabled city; missing entry crashes. **Mitigation**: explicit guard in route; default fallback to 0.005 if undefined.
- **Risk**: `thumb_original_url` may be deprecated. **Mitigation**: viguessr is shipping it in production today; if Mapillary deprecates, swap to `thumb_2048_url`.
- **Risk**: Rare cities with sparse pano coverage (DH 666m windows) might exhaust retries. **Mitigation**: keep delta=0.005 (1.1km) for non-HN cities.
## Rollback
Single commit; revert HEAD to restore current adaptive code. Mapillary deploys with no schema/state changes, so rollback is instant.
@@ -0,0 +1,94 @@
---
title: "Port viguessr Mapillary strategy"
description: "Replace adaptive shrink/grow bbox loop with viguessr's proven approach: per-city fixed `delta` (half-bbox-side) + random-point dart-throw + simple retry-with-new-point. Fixes 'reduce data' 500s in HN/HCM dense areas."
status: pending
priority: P1
effort: 30m
created: 2026-05-10
source: https://github.com/luuvanduc1999/viguessr (assets/js/game.js, obfuscated)
blockedBy: []
blocks: []
---
# Port viguessr Mapillary strategy
## Why
Current `src/lib/mapillary.js` adaptive bbox sizing fails consistently in dense HN/HCM — Mapillary returns 500 'reduce data' when query bbox costs too much to scan. Adaptive grow on empty results worsens this.
viguessr (a sibling vngeoguessr project) ships a working implementation for the same cities. Their approach: **per-city fixed `delta`**, query a small bbox centered on a random point, retry only by re-rolling the random point (never resizing the bbox). The small fixed bbox keeps Mapillary query cost under their cap; the dart-throw eventually finds pano-rich windows.
## Source decoded (from obfuscated game.js)
```js
boundingBoxVN = {
'HN': [105.77, 20.96, 105.88, 21.05, delta=0.003],
'TPHCM': [106.62, 10.71, 106.75, 10.83, delta=0.005],
'HP': [106.65, 20.80, 106.75, 20.90, delta=0.05],
'ND': [106.00, 20.35, 106.25, 20.50, delta=0.005],
'DN': [108.17, 16.00, 108.25, 16.10, delta=0.005],
};
async function getRandomMapillaryImage() {
const lat = Math.random() * (maxLat - minLat) + minLat;
const lng = Math.random() * (maxLong - minLong) + minLong;
const bbox = [lng-delta, lat-delta, lng+delta, lat+delta].map(n => n.toFixed(4)).join(',');
const url = `https://graph.mapillary.com/images?access_token=${TOKEN}&fields=id,thumb_original_url,geometry,is_pano&limit=3&bbox=${bbox}&is_pano=true`;
const json = await (await fetch(url)).json();
if (json.data?.length) {
const panos = json.data.filter(d => d.is_pano);
return panos.length ? panos[rand(panos.length)] : json.data[0];
}
return getRandomMapillaryImage(); // re-roll, same delta
}
```
## What changes locally
### `src/lib/game.js` (CITIES schema)
Add `mapillaryDelta` field to each enabled `CITIES` entry:
| Code | New `mapillaryDelta` | Source rationale |
|---|---|---|
| HN | `0.003` | viguessr value |
| TPHCM | `0.005` | viguessr value |
| DL | `0.005` | not in viguessr; use TPHCM-class default |
| DH | `0.005` | not in viguessr; use TPHCM-class default |
| DN | `0.005` (still disabled) | viguessr value, future-proof |
Export a `cityDeltas` map alongside existing `cityBboxes`/`cityNames`/`cityCenters`.
### `src/lib/mapillary.js` (rewrite)
Replace adaptive loop with viguessr-style: random point inside city bbox, fixed-size sub-bbox of side `2*delta`, re-roll position on empty/error.
Signature change: `fetchMapillaryImages(bbox, delta)`.
### `src/app/api/new-game/route.js`
- Import `cityDeltas` from `lib/game.js`.
- Pass `cityDeltas[cityCode]` to `fetchMapillaryImages`.
- Use `selectedImage.thumb_original_url` (revert from `thumb_2048_url`).
## Phases
| # | Phase | Effort | Status |
|---|---|---|---|
| 01 | [Per-city Delta + Random-Point Fetch](./phase-01-per-city-delta-random-point-fetch.md) | 30m | pending |
## Success Criteria
- HN, TPHCM, DL, DH all return panoramic image on first request (sample ≥ 3 attempts each).
- No `MAX_BBOX_AREA` / `MIN_BBOX_AREA` / shrink/grow logic remains in `src/lib/mapillary.js`.
- `npm run build` passes.
## Out of scope
- Token rotation or rate-limit handling
- Caching successful responses
- Adding more cities (DN remains disabled, future work)
## Manual cleanup after success
- Remove the diagnostic `console.error` of Mapillary URL/body once confirmed stable (or keep as observability).
+13 -6
View File
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { v4 as uuidv4 } from 'uuid';
import { cityNames, cityBboxes } from '../../../lib/game.js';
import { cityNames, cityBboxes, cityDeltas } from '../../../lib/game.js';
import { fetchMapillaryImages } from '../../../lib/mapillary.js';
import { storeGameSession } from '../../../lib/session.js';
@@ -30,17 +30,24 @@ export async function GET(request) {
try {
console.log('Getting images for city:', cityName);
// Get city bbox
// Get city bbox + per-city Mapillary delta (half-side of query window)
const bbox = cityBboxes[cityCode];
const delta = cityDeltas[cityCode];
if (!bbox) {
return NextResponse.json({
success: false,
error: `No bbox found for city: ${cityCode}`
}, { status: 400 });
}
if (!delta) {
return NextResponse.json({
success: false,
error: `No Mapillary delta configured for city: ${cityCode}`
}, { status: 400 });
}
// Fetch images from city bbox
const imageResult = await fetchMapillaryImages(bbox);
// Fetch images from city bbox via random-point dart-throw
const imageResult = await fetchMapillaryImages(bbox, delta);
if (!imageResult.success) {
return NextResponse.json({
@@ -60,8 +67,8 @@ export async function GET(request) {
lng: selectedImage.geometry.coordinates[0]
};
// Use the thumbnail URL for now - Mapillary requires special handling for full panoramas
const imageUrl = selectedImage.thumb_2048_url;
// Use original-resolution panorama URL for PhotoSphere viewer
const imageUrl = selectedImage.thumb_original_url;
// Create or update game session
const currentSessionId = sessionId || generateSessionId();
+11
View File
@@ -7,6 +7,7 @@ export const CITIES = {
name: 'Ha Noi',
center: [21.0285, 105.8542],
bbox: [105.77, 20.96, 105.88, 21.05],
mapillaryDelta: 0.003,
enabled: true
},
DN: {
@@ -14,6 +15,7 @@ export const CITIES = {
name: 'Da Nang',
center: [16.0544, 108.2022],
bbox: [108.17, 16, 108.25, 16.1],
mapillaryDelta: 0.005,
enabled: false
},
TPHCM: {
@@ -21,6 +23,7 @@ export const CITIES = {
name: 'Ho Chi Minh',
center: [10.8231, 106.6297],
bbox: [106.62, 10.71, 106.75, 10.83],
mapillaryDelta: 0.005,
enabled: true
},
DL: {
@@ -28,6 +31,7 @@ export const CITIES = {
name: 'Da Lat',
center: [11.9404, 108.4583],
bbox: [108.38, 11.89, 108.50, 12.00],
mapillaryDelta: 0.005,
enabled: true
},
DH: {
@@ -35,6 +39,7 @@ export const CITIES = {
name: 'Duc Hoa (Long An)',
center: [10.8888, 106.3825],
bbox: [106.35, 10.85, 106.45, 10.95],
mapillaryDelta: 0.005,
enabled: true
}
};
@@ -53,6 +58,12 @@ export const cityBboxes = Object.fromEntries(
Object.values(CITIES).map(city => [city.code, city.bbox])
);
// Per-city Mapillary query bbox half-side (degrees). Smaller for dense urban
// areas to keep Mapillary query cost under their 'reduce data' cap.
export const cityDeltas = Object.fromEntries(
Object.values(CITIES).map(city => [city.code, city.mapillaryDelta])
);
// Cities list for UI components (only enabled cities)
export const cities = Object.values(CITIES)
.filter(city => city.enabled)
+52 -76
View File
@@ -1,97 +1,73 @@
// Mapillary API utilities
//
// Strategy ported from viguessr (https://github.com/luuvanduc1999/viguessr):
// pick a random point inside the city bbox, query a small fixed-size sub-bbox
// (side = 2*delta) centered on that point, and re-roll the point on empty
// results or transient 5xx. The fixed small size keeps Mapillary query cost
// below their cap; the dart-throw eventually lands on a pano-rich window.
// Bounding-box area range (square degrees) used by the adaptive query loop.
// - Too big in dense urban regions → Mapillary returns 500 'reduce data'.
// - Too small in sparse regions → no panoramic images returned.
// Loop shrinks on 500 errors and grows on empty results to converge on a
// workable size per city.
const MIN_BBOX_AREA = 0.00005; // ~250m square
const MAX_BBOX_AREA = 0.003; // ~5.5km square
const MAX_RETRIES = 10;
// Pick a random sub-bbox of the requested area within the parent bbox.
function getRandomSubBbox(bbox, area) {
const [minLng, minLat, maxLng, maxLat] = bbox;
const width = maxLng - minLng;
const height = maxLat - minLat;
const side = Math.sqrt(area) * 0.9; // 10% safety margin
const subWidth = Math.min(side, width);
const subHeight = Math.min(side, height);
const startLng = minLng + Math.random() * Math.max(0, width - subWidth);
const startLat = minLat + Math.random() * Math.max(0, height - subHeight);
return [startLng, startLat, startLng + subWidth, startLat + subHeight];
}
// Fetch panoramic images from a single bbox query.
// Returns { ok, images, retryable, error } so the loop can decide adaptive action.
async function queryMapillary(accessToken, queryBbox) {
const bboxString = queryBbox.join(',');
const apiUrl = `https://graph.mapillary.com/images?access_token=${accessToken}&fields=id,thumb_2048_url,geometry&limit=1&bbox=${bboxString}&is_pano=true`;
const response = await fetch(apiUrl, {
headers: { 'Accept': 'application/json' },
next: { revalidate: 0 }
});
if (!response.ok) {
const body = await response.text().catch(() => '<unreadable>');
if (response.status === 401) {
throw new Error('Mapillary authentication failed');
}
return { ok: false, retryable: true, error: `${response.status}: ${body.slice(0, 200)}` };
}
const data = await response.json();
return { ok: true, images: data.data || [] };
}
const MAX_RETRIES = 15;
const GROW_FACTOR = 1.6;
const SHRINK_FACTOR = 0.5;
export async function fetchMapillaryImages(bbox) {
export async function fetchMapillaryImages(bbox, delta) {
const accessToken = process.env.MAPILLARY_ACCESS_TOKEN;
if (!accessToken) {
throw new Error('MAPILLARY_ACCESS_TOKEN environment variable is not set');
}
// Start at the geometric mean of the size range — a reasonable middle ground.
let area = Math.sqrt(MIN_BBOX_AREA * MAX_BBOX_AREA);
const [minLng, minLat, maxLng, maxLat] = bbox;
let lastError = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const queryBbox = getRandomSubBbox(bbox, area);
let result;
const lat = Math.random() * (maxLat - minLat) + minLat;
const lng = Math.random() * (maxLng - minLng) + minLng;
const queryBbox = [
(lng - delta).toFixed(4),
(lat - delta).toFixed(4),
(lng + delta).toFixed(4),
(lat + delta).toFixed(4),
].join(',');
const apiUrl = `https://graph.mapillary.com/images?access_token=${accessToken}&fields=id,thumb_original_url,geometry,is_pano&limit=3&bbox=${queryBbox}&is_pano=true`;
try {
result = await queryMapillary(accessToken, queryBbox);
const response = await fetch(apiUrl, {
headers: { Accept: 'application/json' },
next: { revalidate: 0 },
});
if (response.status === 401) {
throw new Error('Mapillary authentication failed');
}
if (!response.ok) {
const body = await response.text().catch(() => '<unreadable>');
lastError = `${response.status}: ${body.slice(0, 200)}`;
console.error(`Attempt ${attempt}/${MAX_RETRIES}: Mapillary ${response.status}; re-rolling`);
continue;
}
const data = await response.json();
const images = data.data || [];
const panos = images.filter(img => img.is_pano);
if (panos.length > 0) {
console.log(`Found ${panos.length} panos on attempt ${attempt}`);
return { success: true, data: panos };
}
console.log(`Attempt ${attempt}/${MAX_RETRIES}: no panos; re-rolling`);
} catch (error) {
// Auth failures are not transient — abort fast.
throw error;
}
if (result.ok && result.images.length > 0) {
console.log(`Found ${result.images.length} images on attempt ${attempt} (area=${area.toFixed(5)})`);
return { success: true, data: result.images };
}
if (!result.ok) {
// 'reduce data' / 'unknown error' — shrink and retry.
console.error(`Attempt ${attempt}/${MAX_RETRIES} 500 (area=${area.toFixed(5)}): ${result.error}`);
lastError = result.error;
area = Math.max(MIN_BBOX_AREA, area * SHRINK_FACTOR);
} else {
// OK but empty — grow and retry to widen the search area.
console.log(`Attempt ${attempt}/${MAX_RETRIES} no panos (area=${area.toFixed(5)})`);
area = Math.min(MAX_BBOX_AREA, area * GROW_FACTOR);
if (error.message === 'Mapillary authentication failed') {
throw error;
}
lastError = error.message;
console.error(`Attempt ${attempt}/${MAX_RETRIES} threw: ${error.message}; re-rolling`);
}
}
return {
success: false,
error: lastError
? `No street view images found after ${MAX_RETRIES} attempts (last: ${lastError})`
: 'No street view images found in city bbox',
? `No panos after ${MAX_RETRIES} attempts (last: ${lastError})`
: 'No panos found',
};
}