diff --git a/docs/features.md b/docs/features.md index cee1adc..aa07bee 100644 --- a/docs/features.md +++ b/docs/features.md @@ -7,7 +7,7 @@ ## Street View System - **Mapillary Integration**: Panoramic street-level images via Mapillary API -- **Dart-throw Strategy**: Pick random point in city bbox, query small fixed-size sub-bbox, retry on empty/error (20 attempts max) +- **Dart-throw Strategy**: Pick random points in city bbox, query small fixed-size sub-bboxes, 8 raced concurrently per round, re-roll on empty/error (20 windows max) - **Per-city Optimization**: Delta tuned per city to balance coverage and Mapillary query cost - Ha Noi: 0.003° (~333m square) - Ho Chi Minh, Da Lat, Da Nang, Duc Hoa: 0.005° (~556m square) diff --git a/docs/game-flow.md b/docs/game-flow.md index f68287c..65a74d1 100644 --- a/docs/game-flow.md +++ b/docs/game-flow.md @@ -23,9 +23,10 @@ ### 4. Location Display Process - **Bbox Location Generation**: Server uses pre-defined city bounding boxes for random coordinate generation - **Image Search**: Mapillary API dart-throw strategy (ported from viguessr): - - Pick random point in city bbox - - Query small fixed-size sub-bbox (side = 2×delta) centered on point - - Retry up to 20 times if empty or error (re-roll position) + - Pick random points in city bbox + - Query small fixed-size sub-bboxes (side = 2×delta) centered on each point + - Throw 8 windows concurrently per round; first window with panos wins, losers aborted + - Up to 20 windows total across rounds before giving up - Per-city delta tuned to keep query cost below Mapillary cap (HN: 0.003°, others: 0.005°) - Filter for is_pano=true panoramic images, limit 3 results - **Image Display**: Thumbnail images displayed (thumb_original_url) diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 882b82b..e260ba0 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -7,7 +7,8 @@ ## Street View & Mapping - **Mapillary API**: Panoramic street-level imagery provider (thumb_original_url, is_pano=true filter) - - **Dart-throw strategy**: pick random point in city bbox, query small fixed-size sub-bbox (2×delta), re-roll on empty. Keeps query cost under Mapillary cap. + - **Dart-throw strategy**: pick random points in city bbox, query small fixed-size sub-bboxes (2×delta), re-roll on empty. Keeps query cost under Mapillary cap. + - **Concurrent rounds**: 8 windows raced per round (20 max total). A Mapillary round-trip costs ~1-5s, so racing keeps wall time at one round-trip instead of the sum of every miss. - **Per-city delta**: configurable in CITIES enum (`src/lib/game.js`). HN: 0.003° (~333m), others: 0.005° (~556m) - **Leaflet**: Interactive mapping library for guess placement - **OpenStreetMap**: Map tile provider for base maps diff --git a/src/lib/mapillary.js b/src/lib/mapillary.js index a92b7db..7424d43 100644 --- a/src/lib/mapillary.js +++ b/src/lib/mapillary.js @@ -5,69 +5,112 @@ // (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. +// +// The dart throws run in concurrent rounds rather than one at a time. A single +// Mapillary round-trip costs ~1s (up to ~5s when it answers 500), and most +// windows are empty, so throwing them sequentially made the wall time the sum +// of every miss. Throwing a whole round at once makes it the cost of one +// round-trip: the first window that yields panoramas wins and the losers are +// aborted. -const MAX_RETRIES = 20; +const MAX_ATTEMPTS = 20; +const ATTEMPTS_PER_ROUND = 8; +// Marker error for a window that answered fine but held no panoramas. +const EMPTY_WINDOW = 'no panos in window'; +const AUTH_FAILED = 'Mapillary authentication failed'; + +/** + * Query one random sub-bbox for panoramas. + * Rejects on empty results so the caller can race windows with Promise.any. + * @param {number[]} bbox City bbox as [minLng, minLat, maxLng, maxLat]. + * @param {number} delta Half-side of the query window, in degrees. + * @param {string} accessToken Mapillary access token. + * @param {AbortSignal} signal Aborted once a sibling window wins. + * @returns {Promise} Panoramic images found in the window. + */ +async function probeRandomWindow(bbox, delta, accessToken, signal) { + const [minLng, minLat, maxLng, maxLat] = bbox; + 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`; + + const response = await fetch(apiUrl, { + headers: { Accept: 'application/json' }, + signal, + next: { revalidate: 0 }, + }); + + if (response.status === 401) { + throw new Error(AUTH_FAILED); + } + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`${response.status}: ${body.slice(0, 200)}`); + } + + const data = await response.json(); + const panos = (data.data || []).filter(img => img.is_pano); + if (panos.length === 0) { + throw new Error(EMPTY_WINDOW); + } + return panos; +} + +/** + * Find panoramic images somewhere inside a city bbox. + * @param {number[]} bbox City bbox as [minLng, minLat, maxLng, maxLat]. + * @param {number} delta Half-side of each query window, in degrees. + * @returns {Promise<{success: boolean, data?: Object[], error?: string}>} + */ 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 attemptsSpent = 0; 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`; + while (attemptsSpent < MAX_ATTEMPTS) { + const roundSize = Math.min(ATTEMPTS_PER_ROUND, MAX_ATTEMPTS - attemptsSpent); + const controller = new AbortController(); + const windows = Array.from({ length: roundSize }, () => + probeRandomWindow(bbox, delta, accessToken, controller.signal) + ); try { - const response = await fetch(apiUrl, { - headers: { Accept: 'application/json' }, - next: { revalidate: 0 }, - }); - - if (response.status === 401) { - throw new Error('Mapillary authentication failed'); + const panos = await Promise.any(windows); + // Let the losing windows go; their results are no longer needed. + controller.abort(); + console.log(`Found ${panos.length} panos after ${attemptsSpent + roundSize} windows`); + return { success: true, data: panos }; + } catch (aggregate) { + const errors = aggregate.errors || [aggregate]; + const authFailure = errors.find(err => err.message === AUTH_FAILED); + if (authFailure) { + throw authFailure; } - - if (!response.ok) { - const body = await response.text().catch(() => ''); - lastError = `${response.status}: ${body.slice(0, 200)}`; - console.error(`Attempt ${attempt}/${MAX_RETRIES}: Mapillary ${response.status}; re-rolling`); - continue; + const realFailure = errors.find(err => err.message !== EMPTY_WINDOW); + if (realFailure) { + lastError = realFailure.message; } - - 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`); + attemptsSpent += roundSize; + console.log(`${attemptsSpent}/${MAX_ATTEMPTS} windows tried; no panos yet`); } } return { success: false, error: lastError - ? `No panos after ${MAX_RETRIES} attempts (last: ${lastError})` + ? `No panos after ${MAX_ATTEMPTS} windows (last: ${lastError})` : 'No panos found', }; }