mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(voice): resolve areas per view, without accents and per natural place
From review:
- An answer the view picked ("Springfield" over Illinois) is reused or
shared only within the same ~2° view cell, so asking again elsewhere no
longer gets the first state's area.
- Admin names compare with accents folded on Latin letters only: "Quebec"
matches "Québec" and "Cordoba" asks between Argentina and Spain, while a
Thai or Devanagari mark still tells names apart.
- A Natural Earth name used by several features is one place with every
part when they touch (the Great Rift Valley, the Lau Group across 180°)
and a question when they are far apart (Flores); natural places can now
be candidates.
- The AREAS rule and the area tool descriptions are tighter, leaving a
~270-character margin under the 55,500 prompt ceiling, and resolve_area
no longer names osm_query when that tool is not offered.
This commit is contained in:
1 parent
fb74354d30
commit
207373389a
12 files changed
+328
-22
No files matched your search
@@ -84,8 +84,29 @@ const ADMIN_LEVEL = Object.freeze({
|
||||
country: 'country',
|
||||
state: 'admin1',
|
||||
county: 'admin2',
|
||||
natural: 'natural',
|
||||
});
|
||||
|
||||
/**
|
||||
* One of several same-name natural places as a clarification choice, located
|
||||
* by its rough centre ("39 N 31 W") since it has no country.
|
||||
*/
|
||||
function naturalCandidate(place, kind) {
|
||||
const { lat, lon } = place.center || {};
|
||||
const locator = Number.isFinite(lat)
|
||||
? `${Math.abs(Math.round(lat))} ${lat >= 0 ? 'N' : 'S'} ${Math.abs(Math.round(lon))} ${lon >= 0 ? 'E' : 'W'}`
|
||||
: '';
|
||||
return {
|
||||
kind: 'natural',
|
||||
name: place.name,
|
||||
country: '',
|
||||
regionCode: locator,
|
||||
polygons: place.polygons.map((ring) => [ring]),
|
||||
source: 'natural-earth',
|
||||
meta: { kind },
|
||||
};
|
||||
}
|
||||
|
||||
const ADMIN_NAME_LEVEL = Object.freeze({
|
||||
country: 'country',
|
||||
nation: 'country',
|
||||
@@ -101,6 +122,18 @@ const ADMIN_NAME_LEVEL = Object.freeze({
|
||||
department: 'admin2',
|
||||
});
|
||||
|
||||
/**
|
||||
* Drop accents from Latin letters only, for comparing names: speech often
|
||||
* loses them ("Quebec" for "Québec"), while a Thai or Devanagari mark changes
|
||||
* the name and is kept.
|
||||
*/
|
||||
export function foldLatinMarks(text) {
|
||||
return String(text || '')
|
||||
.normalize('NFD')
|
||||
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
|
||||
.normalize('NFC');
|
||||
}
|
||||
|
||||
function explicitAdminName(text) {
|
||||
const normalized = normalizeAreaQuery(String(text).split(',')[0]);
|
||||
const match = normalized.match(
|
||||
@@ -108,7 +141,9 @@ function explicitAdminName(text) {
|
||||
);
|
||||
return {
|
||||
normalized,
|
||||
bare: match ? normalized.slice(0, -match[0].length).trim() : normalized,
|
||||
bare: foldLatinMarks(
|
||||
match ? normalized.slice(0, -match[0].length).trim() : normalized,
|
||||
),
|
||||
word: match?.[1] || null,
|
||||
level: match ? ADMIN_NAME_LEVEL[match[1]] || null : null,
|
||||
};
|
||||
@@ -191,6 +226,13 @@ function describeCandidate(admin) {
|
||||
* itself stands for its same-named state ("Mexico" is the country, not the
|
||||
* State of Mexico); across countries each name is its own place.
|
||||
*/
|
||||
/** A coarse ~2° cell of the view, or "none", for keying view-picked answers. */
|
||||
function viewCell(center) {
|
||||
if (!Number.isFinite(center?.lat) || !Number.isFinite(center?.lon))
|
||||
return 'none';
|
||||
return `${Math.floor(center.lat / 2)},${Math.floor(center.lon / 2)}`;
|
||||
}
|
||||
|
||||
export function distinctAdminPlaces(candidates) {
|
||||
const byCountry = new Map();
|
||||
for (const admin of candidates) {
|
||||
@@ -290,16 +332,37 @@ export function createAreaResolver({
|
||||
// "Punjab" the desert) never hides it; asked for by level, it answers.
|
||||
// Only a unit that really has this name counts: "Great Britain" finds
|
||||
// the United Kingdom by alias, which is not a Great Britain.
|
||||
// A feature split into parts is one place with every part; distant
|
||||
// namesakes (Flores in the Azores and Indonesia) are asked about.
|
||||
const places = ne?.places?.length
|
||||
? ne.places
|
||||
: ne?.polygons?.length
|
||||
? [{ name: ne.name, polygons: ne.polygons }]
|
||||
: [];
|
||||
const shadowsAdmin =
|
||||
ne?.polygons?.length &&
|
||||
places.length > 0 &&
|
||||
level !== 'natural' &&
|
||||
(await findAdminCandidates(query, { signal }).catch(() => [])).some(
|
||||
(admin) => !bundledAdminIdentityRefusal(admin, query, null),
|
||||
);
|
||||
signal?.throwIfAborted?.();
|
||||
if (ne?.polygons?.length && !shadowsAdmin) {
|
||||
if (places.length > 1 && !shadowsAdmin) {
|
||||
const offered = places
|
||||
.slice(0, MAX_AREA_CANDIDATES)
|
||||
.map((place) => naturalCandidate(place, ne.kind));
|
||||
const identity = { query, wantedLevel: 'natural' };
|
||||
offered.forEach((admin) => remember(admin, identity));
|
||||
return {
|
||||
ok: false,
|
||||
needsClarification: true,
|
||||
code: 'AMBIGUOUS_AREA',
|
||||
candidates: offered.map(describeCandidate),
|
||||
error: `"${query}" names ${offered.length} places — ask which one.`,
|
||||
};
|
||||
}
|
||||
if (places.length === 1 && !shadowsAdmin) {
|
||||
const record = store.put({
|
||||
geometry: ne.polygons.map((ring) => [ring]),
|
||||
geometry: places[0].polygons.map((ring) => [ring]),
|
||||
name: ne.name,
|
||||
level: 'natural',
|
||||
source: 'natural-earth',
|
||||
@@ -472,9 +535,11 @@ export function createAreaResolver({
|
||||
error: 'Name a place to resolve.',
|
||||
});
|
||||
const nameKey = normalizeAreaQuery(query);
|
||||
// A name that normalizes to nothing is never cached or shared.
|
||||
// The outline ladder and geocoder pick by the view ("Springfield" over
|
||||
// Illinois), so an answer is reused or shared only within the same
|
||||
// ~2° view cell. A name that normalizes to nothing is never cached.
|
||||
const key = nameKey
|
||||
? `${nameKey}|${level || ''}|${normalizeAreaQuery(within)}`
|
||||
? `${nameKey}|${level || ''}|${normalizeAreaQuery(within)}|${viewCell(viewCenter())}`
|
||||
: null;
|
||||
const recalled = key ? store.recallQuery(key) : null;
|
||||
if (recalled)
|
||||
|
||||
@@ -725,3 +725,139 @@ test('a bundled name mismatch falls through to the outline ladder', async () =>
|
||||
'the bundled refusal is kept',
|
||||
);
|
||||
});
|
||||
|
||||
test('a view-picked outline answer is not reused for the same name elsewhere', async () => {
|
||||
let view = { lat: 39.8, lon: -89.6 }; // Springfield, Illinois
|
||||
const springfield = (center) => ({
|
||||
ok: true,
|
||||
name:
|
||||
center.lon < -80 ? 'Springfield, Illinois' : 'Springfield, Massachusetts',
|
||||
polygons: [
|
||||
[
|
||||
square(
|
||||
center.lon - 0.1,
|
||||
center.lat - 0.1,
|
||||
center.lon + 0.1,
|
||||
center.lat + 0.1,
|
||||
),
|
||||
],
|
||||
],
|
||||
source: 'osm',
|
||||
rung: 'nominatim',
|
||||
});
|
||||
const { resolver } = makeResolver({
|
||||
viewCenter: () => view,
|
||||
resolveNamedOutline: async () => springfield(view),
|
||||
});
|
||||
const illinois = await resolver.resolve({ query: 'Springfield' });
|
||||
assert.equal(illinois.record.name, 'Springfield, Illinois');
|
||||
const again = await resolver.resolve({ query: 'Springfield' });
|
||||
assert.equal(again.cached, true, 'the same view still reuses it');
|
||||
view = { lat: 42.1, lon: -72.6 }; // Springfield, Massachusetts
|
||||
const massachusetts = await resolver.resolve({ query: 'Springfield' });
|
||||
assert.equal(massachusetts.cached, false);
|
||||
assert.equal(massachusetts.record.name, 'Springfield, Massachusetts');
|
||||
});
|
||||
|
||||
test('a name spoken without accents matches its accented unit, Latin letters only', async () => {
|
||||
const quebec = admin('state', 'Québec', 'Canada', [-79.8, 45, -57.1, 62.6]);
|
||||
const cordobaAr = admin(
|
||||
'state',
|
||||
'Córdoba',
|
||||
'Argentina',
|
||||
[-65.8, -35, -61.8, -29.5],
|
||||
);
|
||||
const cordobaEs = admin('state', 'Córdoba', 'Spain', [-5.6, 37.2, -4, 38.7]);
|
||||
const thai = admin('state', 'ป่าย', 'Thailand', [98, 19, 99, 20]);
|
||||
const { resolver } = makeResolver({
|
||||
findAdminCandidates: async (q) =>
|
||||
/^quebec$/i.test(q)
|
||||
? [quebec]
|
||||
: /^cordoba$/i.test(q)
|
||||
? [cordobaAr, cordobaEs]
|
||||
: q === 'ปาย'
|
||||
? [thai]
|
||||
: [],
|
||||
});
|
||||
const qc = await resolver.resolve({ query: 'Quebec' });
|
||||
assert.equal(qc.ok, true, qc.error);
|
||||
assert.equal(qc.record.name, 'Québec');
|
||||
const cordoba = await resolver.resolve({ query: 'Cordoba' });
|
||||
assert.equal(cordoba.code, 'AMBIGUOUS_AREA', 'both Córdobas are asked about');
|
||||
assert.equal(cordoba.candidates.length, 2);
|
||||
const tone = await resolver.resolve({ query: 'ปาย' });
|
||||
assert.equal(tone.ok, false, 'a Thai tone mark still tells names apart');
|
||||
});
|
||||
|
||||
test('a natural name for two distant places is asked about, and the choice resolves', async () => {
|
||||
const azores = {
|
||||
name: 'Flores',
|
||||
featurecla: 'Island',
|
||||
polygons: [square(-31.3, 39.4, -31.1, 39.5)],
|
||||
areaKm2: 140,
|
||||
center: { lat: 39.45, lon: -31.2 },
|
||||
};
|
||||
const indonesia = {
|
||||
name: 'Flores',
|
||||
featurecla: 'Island',
|
||||
polygons: [square(119.8, -8.9, 123, -8.1)],
|
||||
areaKm2: 14000,
|
||||
center: { lat: -8.5, lon: 121.4 },
|
||||
};
|
||||
const { resolver } = makeResolver({
|
||||
findNaturalRegion: async (q) =>
|
||||
/^flores$/i.test(q)
|
||||
? {
|
||||
name: 'Flores',
|
||||
kind: 'natural',
|
||||
polygons: indonesia.polygons,
|
||||
places: [indonesia, azores],
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const asked = await resolver.resolve({ query: 'Flores' });
|
||||
assert.equal(asked.code, 'AMBIGUOUS_AREA');
|
||||
assert.equal(asked.candidates.length, 2);
|
||||
assert.ok(asked.candidates.every((c) => c.level === 'natural' && c.region));
|
||||
const picked = await resolver.resolve({
|
||||
candidateId: asked.candidates[1].candidateId,
|
||||
});
|
||||
assert.equal(picked.ok, true, picked.error);
|
||||
assert.equal(picked.record.level, 'natural');
|
||||
assert.equal(
|
||||
pointInPreparedArea(picked.record.prepared, 39.45, -31.2),
|
||||
true,
|
||||
'the Azores Flores',
|
||||
);
|
||||
});
|
||||
|
||||
test('a natural feature in several parts is stored whole', async () => {
|
||||
const north = square(33.7, 2.4, 44.2, 15.4);
|
||||
const south = square(28.9, -15.6, 35.8, 2.4);
|
||||
const { resolver } = makeResolver({
|
||||
findNaturalRegion: async (q) =>
|
||||
/rift/i.test(q)
|
||||
? {
|
||||
name: 'Great Rift Valley',
|
||||
kind: 'natural',
|
||||
polygons: [north],
|
||||
places: [
|
||||
{
|
||||
name: 'Great Rift Valley',
|
||||
featurecla: 'Valley',
|
||||
polygons: [north, south],
|
||||
areaKm2: 2,
|
||||
center: { lat: 0, lon: 36 },
|
||||
},
|
||||
],
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const rift = await resolver.resolve({ query: 'Great Rift Valley' });
|
||||
assert.equal(rift.ok, true);
|
||||
assert.equal(
|
||||
pointInPreparedArea(rift.record.prepared, -10, 33),
|
||||
true,
|
||||
'the southern part counts',
|
||||
);
|
||||
});
|
||||
@@ -189,7 +189,58 @@ const loadIndex = createRetryableLoader(async () => {
|
||||
return index;
|
||||
});
|
||||
|
||||
function toResult(entry, candidates = 1) {
|
||||
/** Same-name parts this close are one feature split by the pack. */
|
||||
const SAME_PLACE_GAP_KM = 100;
|
||||
|
||||
/** Gap between two bboxes in km, measured the short way across 180°. */
|
||||
function bboxGapKm(a, b) {
|
||||
const midLat = (a[1] + a[3] + b[1] + b[3]) / 4;
|
||||
const kmPerLon = 111.32 * Math.cos((midLat * Math.PI) / 180);
|
||||
let dLon = Infinity;
|
||||
for (const shift of [-360, 0, 360]) {
|
||||
dLon = Math.min(
|
||||
dLon,
|
||||
Math.max(0, Math.max(a[0], b[0] + shift) - Math.min(a[2], b[2] + shift)),
|
||||
);
|
||||
}
|
||||
const dLat = Math.max(0, Math.max(a[1], b[1]) - Math.min(a[3], b[3]));
|
||||
return Math.hypot(dLon * kmPerLon, dLat * 111.32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct places among same-name entries: parts that touch or nearly touch
|
||||
* (the Great Rift Valley's two halves, the Lau Group across 180°) merge into
|
||||
* one place with every part; distant namesakes (Flores in the Azores and in
|
||||
* Indonesia) stay separate. Largest place first.
|
||||
*/
|
||||
function groupPlaces(list) {
|
||||
const groups = [];
|
||||
for (const entry of list) {
|
||||
const near = groups.filter((group) =>
|
||||
group.some(
|
||||
(other) => bboxGapKm(other.bbox, entry.bbox) <= SAME_PLACE_GAP_KM,
|
||||
),
|
||||
);
|
||||
const merged = [entry, ...near.flat()];
|
||||
for (const group of near) groups.splice(groups.indexOf(group), 1);
|
||||
groups.push(merged);
|
||||
}
|
||||
return groups
|
||||
.map((group) => {
|
||||
const largest = group.reduce((a, b) => (b.areaKm2 > a.areaKm2 ? b : a));
|
||||
const [w, s, e, n] = largest.bbox;
|
||||
return {
|
||||
name: largest.name,
|
||||
featurecla: largest.featurecla,
|
||||
polygons: group.flatMap((entry) => entry.polygons),
|
||||
areaKm2: group.reduce((sum, entry) => sum + entry.areaKm2, 0),
|
||||
center: { lat: (s + n) / 2, lon: (w + e) / 2 },
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.areaKm2 - a.areaKm2);
|
||||
}
|
||||
|
||||
function toResult(entry, candidates = 1, list = [entry]) {
|
||||
return {
|
||||
name: entry.name,
|
||||
featurecla: entry.featurecla,
|
||||
@@ -198,6 +249,7 @@ function toResult(entry, candidates = 1) {
|
||||
bboxDiagonalKm: entry.bboxDiagonalKm,
|
||||
areaKm2: entry.areaKm2,
|
||||
candidates,
|
||||
places: groupPlaces(list),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,9 +262,12 @@ function toResult(entry, candidates = 1) {
|
||||
* @param {string} query e.g. "the Alps", "Rockies", "Gulf of Mexico"
|
||||
* @returns {Promise<{name:string, featurecla:string, kind:'natural'|'marine',
|
||||
* polygons:Array<Array<[number,number]>>, bboxDiagonalKm:number,
|
||||
* areaKm2:number, candidates:number}|null>} largest-area match plus the
|
||||
* number of same-name entries, or null when nothing matches. Uniqueness in
|
||||
* this pack alone does not establish the user's intended place.
|
||||
* areaKm2:number, candidates:number, places:Array<{name:string,
|
||||
* featurecla:string, polygons:Array, areaKm2:number,
|
||||
* center:{lat:number, lon:number}}>}|null>} largest-area match plus the
|
||||
* number of same-name entries, or null when nothing matches. `places`
|
||||
* groups those entries into distinct places, each with all its parts.
|
||||
* Uniqueness in this pack alone does not establish the user's intended place.
|
||||
*/
|
||||
export async function findNaturalRegion(query) {
|
||||
const norm = normalizeName(query);
|
||||
@@ -226,7 +281,7 @@ export async function findNaturalRegion(query) {
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const list = index.get(key);
|
||||
if (list && list.length) return toResult(list[0], list.length);
|
||||
if (list && list.length) return toResult(list[0], list.length, list);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -195,3 +195,21 @@ test('pointInRing: basic square', () => {
|
||||
assert.equal(pointInRing(sq, 15, 5), false);
|
||||
assert.equal(pointInRing([[0, 0], [1, 1]], 0.5, 0.5), false, 'degenerate ring');
|
||||
});
|
||||
|
||||
test('same-name parts of one feature are one place; distant namesakes are separate places', async () => {
|
||||
for (const name of ['Great Rift Valley', 'Lau Group', 'Transantarctic Mountains']) {
|
||||
const region = await findNaturalRegion(name);
|
||||
assert.equal(region?.places?.length, 1, `${name} is one place`);
|
||||
const place = region.places[0];
|
||||
assert.equal(place.polygons.length >= 2, true, `${name} keeps every part`);
|
||||
assert.ok(place.areaKm2 > region.areaKm2, `${name} counts more than its largest part`);
|
||||
}
|
||||
for (const name of ['Flores', 'Cordillera Oriental']) {
|
||||
const region = await findNaturalRegion(name);
|
||||
assert.equal(region?.places?.length, 2, `${name} names two places`);
|
||||
for (const place of region.places) {
|
||||
assert.ok(Number.isFinite(place.center.lat) && Number.isFinite(place.center.lon));
|
||||
}
|
||||
}
|
||||
assert.equal((await findNaturalRegion('Alps'))?.places?.length, 1);
|
||||
});
|
||||
Reference in new issue
Block a user