address the final review of the voice outline fixes

- A building word ("Burj Khalifa Tower", "Sagrada Familia Church") passes
  the last-word rule on a building-like result; enclosures stay out.
- The anchor chooses only among the best-named outlines, so a worse name
  never wins by containment alone.
- An ask without a quadrant takes only the quadrant of the matching street
  nearest the point; a leading quadrant ("SE Main St") may go unsaid too.
- On an unbuffered tile edge a fragment shows as an edge along the
  boundary, so a whole building touching it at a corner is not cut.
- Drop two orphaned comments and correct the place-name key wording.
This commit is contained in:
Sameh Khamis committed 2026-11-02 09:03:36 -08:00
1 parent a61d0f4c2a
commit 0712db9891
8 files changed
+194 -42

No files matched your search

+62 -23
View File
@@ -204,14 +204,28 @@ function decodePolygons(
}
const [westCut, northCut] = pixelToLonLat(minX, minY, extent, z, x, y);
const [eastCut, southCut] = pixelToLonLat(maxX, maxY, extent, z, x, y);
// A side with no buffer was cut at the tile edge itself, so a polygon that
// touches it is a fragment too.
// Past a buffer, any vertex on the cut line marks a fragment. A side with
// no buffer was cut at the tile edge itself, where a whole building may
// also just touch the edge: there a fragment shows as an edge running
// along the boundary, not a single vertex on it.
const eps = 1e-9;
const onCut = ([lon, lat]) =>
Math.abs(lon - westCut) < eps ||
Math.abs(lon - eastCut) < eps ||
Math.abs(lat - northCut) < eps ||
Math.abs(lat - southCut) < eps;
const near = (a, b) => Math.abs(a - b) < eps;
const onBufferedCut = ([lon, lat]) =>
(minX < 0 && near(lon, westCut)) ||
(maxX > extent && near(lon, eastCut)) ||
(minY < 0 && near(lat, northCut)) ||
(maxY > extent && near(lat, southCut));
const alongEdge = ([lonA, latA], [lonB, latB]) =>
(minX === 0 && near(lonA, westCut) && near(lonB, westCut)) ||
(maxX === extent && near(lonA, eastCut) && near(lonB, eastCut)) ||
(minY === 0 && near(latA, northCut) && near(latB, northCut)) ||
(maxY === extent && near(latA, southCut) && near(latB, southCut));
const isCut = (ring) =>
ring.some(
(point, index) =>
onBufferedCut(point) ||
(index > 0 && alongEdge(ring[index - 1], point)),
);
const out = [];
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
@@ -245,7 +259,7 @@ function decodePolygons(
),
);
const clippedParts = polygons.map(
(rings, part) => incompleteParts[part] || rings[0].some(onCut),
(rings, part) => incompleteParts[part] || isCut(rings[0]),
);
for (let part = 0; part < polygons.length; part++) {
const rings = polygons[part];
@@ -364,14 +378,24 @@ export function streetNameMatches(candidate, asked) {
};
const have = normalizeStreetName(candidate);
if (answers(have)) return true;
const quadrant = streetQuadrant(have);
if (!quadrant || streetQuadrant(want)) return false;
const words = have.split(' ');
return (
QUADRANTS.has(words.at(-1)) &&
!QUADRANTS.has(want.split(' ').at(-1)) &&
answers(words.slice(0, -1).join(' '))
return answers(
(quadrant.leading ? words.slice(1) : words.slice(0, -1)).join(' '),
);
}
/** A normalized street name's quadrant ("e street northwest", "southeast main street"). */
function streetQuadrant(name) {
const words = name.split(' ');
if (words.length < 2) return null;
if (QUADRANTS.has(words.at(-1)))
return { name: words.at(-1), leading: false };
if (QUADRANTS.has(words[0])) return { name: words[0], leading: true };
return null;
}
const lengthM = (line) => {
let total = 0;
for (let i = 1; i < line.length; i++)
@@ -399,23 +423,38 @@ export function streetFromTiles(
const pieces = [];
let matchedName = null;
const extent = boxAround(lat, lon, radiusM);
const matches = [];
for (const tile of tiles || []) {
for (const street of tile?.streets || []) {
if (
!streetNameMatches(street.name, name) &&
!(street.nameEn && streetNameMatches(street.nameEn, name))
)
continue;
const spelled = streetNameMatches(street.name, name)
? street.name
: street.nameEn && streetNameMatches(street.nameEn, name)
? street.nameEn
: null;
if (!spelled) continue;
// Segment distance, not vertex distance: a long straight piece through
// the point has no vertex near it.
if (distanceToLineM(lon, lat, street.coordinates) > radiusM) continue;
// Only what lies within the search extent is drawn.
const clipped = clipTileLine(street.coordinates, extent);
if (!clipped.length) continue;
matchedName ||= street.name;
pieces.push(...clipped);
const distanceM = distanceToLineM(lon, lat, street.coordinates);
if (distanceM > radiusM) continue;
const quadrant =
streetQuadrant(normalizeStreetName(spelled))?.name || null;
matches.push({ street, distanceM, quadrant });
}
}
// An ask without a quadrant ("E Street" where E St NW and E St NE both
// run) means the one beside the point, not every quadrant's street.
const nearest = matches.reduce(
(best, match) => (!best || match.distanceM < best.distanceM ? match : best),
null,
);
for (const { street, quadrant } of matches) {
if (quadrant !== nearest.quadrant) continue;
// Only what lies within the search extent is drawn.
const clipped = clipTileLine(street.coordinates, extent);
if (!clipped.length) continue;
matchedName ||= street.name;
pieces.push(...clipped);
}
if (!pieces.length) return null;
const chains = stitchLines(
pieces.filter((piece) => piece.length >= 2),
+30
View File
@@ -84,6 +84,36 @@ test('a lone letter is a direction only beside a name; quadrants may go unsaid',
assert.ok(!streetNameMatches('E St NE', 'E Street Northwest'));
assert.ok(!streetNameMatches('E Street', 'E Street Northwest'));
assert.ok(!streetNameMatches('F Street Northwest', 'E Street'));
// A leading quadrant may go unsaid too (Portland's "SE Main St").
assert.ok(streetNameMatches('SE Main St', 'Main Street'));
assert.ok(!streetNameMatches('SE Main St', 'NE Main Street'));
});
test('an ask without a quadrant takes the quadrant beside the point', () => {
const street = (name, lat) => ({
name,
coordinates: [
[-77.03, lat],
[-77.02, lat],
],
});
const tiles = [
{
streets: [
street('E Street Northwest', 38.8962),
street('E Street Northeast', 38.8972),
street('E Street Southeast', 38.8822),
],
},
];
const near = streetFromTiles(tiles, {
name: 'E Street',
lat: 38.8963,
lon: -77.025,
});
assert.equal(near.name, 'E Street Northwest');
assert.equal(near.lines.length, 1, 'one quadrant, not three streets');
assert.ok(near.lines[0].every(([, lat]) => lat === 38.8962));
});
test('same-name segments merge into polylines around the point (Congress Avenue)', () => {
@@ -217,6 +217,17 @@ test('grounds touching an unbuffered tile edge are cut, not complete', () => {
const inside = decode([square(-179.999, 0, 0.01)]);
assert.equal(inside.areas[0].clipped, false);
assert.ok(enclosingAreaFromTiles([inside], ask));
// A whole shape that only touches the edge at one corner is not cut.
const tangent = decode([
[
[-180, 0.005],
[-179.995, 0],
[-179.99, 0.005],
[-179.995, 0.01],
[-180, 0.005],
],
]);
assert.equal(tangent.areas[0].clipped, false);
});
test('a point in a courtyard is not inside the building', () => {
@@ -1347,6 +1347,40 @@ test('a landmark ask is not answered by the place around it', () => {
landmark('Royal Botanic Park'),
);
assert.equal(every.outline.name, 'Royal Botanic Historic Park');
// A building word passes on a building-like result, not on a park.
for (const [query, result] of [
[
'Burj Khalifa Tower',
at('Burj Khalifa', 'Burj Khalifa, Downtown Dubai, Dubai', {
category: 'building',
type: 'yes',
}),
],
[
'Sagrada Familia Church',
at('Basílica de la Sagrada Família', 'Basílica, Barcelona, Spain', {
category: 'amenity',
type: 'place_of_worship',
}),
],
])
assert.ok(selectOutlineResult([result], landmark(query)).outline, query);
assert.equal(
selectOutlineResult([park('Central Park')], landmark('Central Park Tower'))
.outline,
null,
);
});
test('a worse name never wins the anchor by containment alone', () => {
const building = {
osm: 'way/1',
polygons: [square(0, 0, 0.0001)],
};
const plaza = { osm: 'way/2', polygons: [square(-0.001, -0.001, 0.003)] };
const anchor = [0.0002, 0.0002];
assert.equal(outlineAt([building, plaza], ...anchor, [3, 2]).osm, 'way/1');
assert.equal(outlineAt([building, plaza], ...anchor, [3, 3]).osm, 'way/2');
});
test('outlineAt reads a ring crossing the antimeridian in its own frame', () => {