mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(maps): resolve google place links to the place pin instead of the camera
A Google place URL carries two points: @lat,lng is the centre of the map view, the !3d!4d data param is the place itself. extractCoords took the camera first, so a pasted share link put the place around 170 m off and reverse geocoded the wrong street. The data param is now read before @; the order of @ and ?q=/?ll= is unchanged. REST and the resolve_maps_url MCP tool share the resolver, so both pick this up. Refs #2635
This commit is contained in:
@@ -34,12 +34,14 @@ export type MapsLink =
|
||||
{ kind: 'resolved'; result: ResolvedMapsUrl } | { kind: 'amap'; lat: number; lng: number; name: string | null };
|
||||
|
||||
// Extract coordinates from a string (URL or page body). Google Maps encodes
|
||||
// them several ways: /@lat,lng,zoom · !3dlat!4dlng (map data param) · ?q=/?ll=.
|
||||
// them several ways: !3dlat!4dlng (map data param) · /@lat,lng,zoom · ?q=/?ll=.
|
||||
// The data param is the place itself, while @ is only where the camera looks,
|
||||
// often a hundred metres off on a place link, so the data param wins (#2635).
|
||||
function extractCoords(s: string): { lat: number; lng: number } | null {
|
||||
const at = s.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (at) return { lat: Number.parseFloat(at[1]), lng: Number.parseFloat(at[2]) };
|
||||
const data = s.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
||||
if (data) return { lat: Number.parseFloat(data[1]), lng: Number.parseFloat(data[2]) };
|
||||
const at = s.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (at) return { lat: Number.parseFloat(at[1]), lng: Number.parseFloat(at[2]) };
|
||||
const q = s.match(/[?&](?:q|ll)=(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (q) return { lat: Number.parseFloat(q[1]), lng: Number.parseFloat(q[2]) };
|
||||
return null;
|
||||
|
||||
@@ -865,6 +865,89 @@ describe('resolveGoogleMapsUrl coordinate extraction (ReDoS guards)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A Google place URL carries two points: @lat,lng is where the camera looks,
|
||||
// !3d!4d is the place itself. Pinning the camera lands next to the place (#2635).
|
||||
describe('resolveGoogleMapsUrl place pin before camera (#2635)', () => {
|
||||
// What https://maps.app.goo.gl/CdynKSHT16bufnAp9 redirects to.
|
||||
const EIFFEL_SHARE_TARGET =
|
||||
'https://www.google.com/maps/place/Tour+Eiffel/@48.8579431,2.292255,812m/data=!3m1!1e3!4m7!3m6!1s0x47e66e2964e34e2d:0x8ddca9ee380ef7e0!8m2!3d48.8583701!4d2.2944813!15sChFtYXBzIGVpZmZlbCB0b3dlcloTIhFtYXBzIGVpZmZlbCB0b3dlcpIBE2hpc3RvcmljYWxfbGFuZG1hcmuaAURDaTlEUVVsUlFVTnZaRU5vZEhsalJqbHZUMnR3VGxkWGRFaFBWM2gyVFd3NWVtTnVTalJNVlU1RVUxWm9ORTlWUlJBQuABAPoBBAgAEDg!16zL20vMDJqODE?entry=tts&g_ep=EgoyMDI2MTAwNy4wIPu8ASoASAFQAw%3D%3D&skid=20b521f5-0f45-4a02-8475-b2a1c2bdcb0b';
|
||||
|
||||
const nominatimAt = (calls: string[]) =>
|
||||
vi.fn(async (u: string) => {
|
||||
calls.push(u);
|
||||
if (u.includes('goo.gl')) return { url: EIFFEL_SHARE_TARGET };
|
||||
return { ok: true, json: async () => ({ display_name: 'Avenue Gustave Eiffel, Paris', name: null, address: {} }) };
|
||||
});
|
||||
|
||||
it('MAPS-2635-001: a place URL with both @ and !3d!4d resolves to the place, not the camera', async () => {
|
||||
const calls: string[] = [];
|
||||
vi.stubGlobal('fetch', nominatimAt(calls));
|
||||
const result = await svc.resolveGoogleMapsUrl(EIFFEL_SHARE_TARGET);
|
||||
expect(result.lat).toBe(48.8583701);
|
||||
expect(result.lng).toBe(2.2944813);
|
||||
expect(result.name).toBe('Tour Eiffel');
|
||||
expect(result.google_ftid).toBe('0x47e66e2964e34e2d:0x8ddca9ee380ef7e0');
|
||||
// The address is looked up at the place too.
|
||||
const reverse = calls.find((u) => u.includes('nominatim'));
|
||||
expect(reverse).toContain('lat=48.8583701');
|
||||
expect(reverse).toContain('lon=2.2944813');
|
||||
});
|
||||
|
||||
it('MAPS-2635-002: the share short link resolves to the place pin', async () => {
|
||||
const calls: string[] = [];
|
||||
vi.stubGlobal('fetch', nominatimAt(calls));
|
||||
const result = await svc.resolveGoogleMapsUrl('https://maps.app.goo.gl/CdynKSHT16bufnAp9');
|
||||
expect(result.lat).toBe(48.8583701);
|
||||
expect(result.lng).toBe(2.2944813);
|
||||
});
|
||||
|
||||
it('MAPS-2635-003: only !3d!4d still resolves', async () => {
|
||||
vi.stubGlobal('fetch', nominatimStub);
|
||||
const result = await svc.resolveGoogleMapsUrl(
|
||||
'https://www.google.com/maps/place/Tour+Eiffel/data=!4m7!3m6!8m2!3d48.8583701!4d2.2944813',
|
||||
);
|
||||
expect(result.lat).toBe(48.8583701);
|
||||
expect(result.lng).toBe(2.2944813);
|
||||
});
|
||||
|
||||
it('MAPS-2635-004: only @ still resolves to the camera point', async () => {
|
||||
vi.stubGlobal('fetch', nominatimStub);
|
||||
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.8579431,2.292255,812m');
|
||||
expect(result.lat).toBe(48.8579431);
|
||||
expect(result.lng).toBe(2.292255);
|
||||
});
|
||||
|
||||
it('MAPS-2635-005: only ?q= still resolves', async () => {
|
||||
vi.stubGlobal('fetch', nominatimStub);
|
||||
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?q=48.8583701,2.2944813');
|
||||
expect(result.lat).toBe(48.8583701);
|
||||
expect(result.lng).toBe(2.2944813);
|
||||
});
|
||||
|
||||
it('MAPS-2635-006: @ keeps its precedence over ?q= when no place pin is present', async () => {
|
||||
vi.stubGlobal('fetch', nominatimStub);
|
||||
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.85,2.29,15z?q=40.6892,-74.0445');
|
||||
expect(result.lat).toBe(48.85);
|
||||
expect(result.lng).toBe(2.29);
|
||||
});
|
||||
|
||||
it('MAPS-2635-007: a page body with both @ and !3d!4d resolves to the place', async () => {
|
||||
const fetchMock = vi.fn(async (u: string) => {
|
||||
if (u.includes('nominatim')) {
|
||||
return { ok: true, json: async () => ({ display_name: 'Paris', name: null, address: {} }) };
|
||||
}
|
||||
return {
|
||||
url: 'https://www.google.com/maps/place/Somewhere',
|
||||
text: async () => 'x/@48.8579431,2.292255,812m/y!3d48.8583701!4d2.2944813z',
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?cid=999');
|
||||
expect(result.lat).toBe(48.8583701);
|
||||
expect(result.lng).toBe(2.2944813);
|
||||
});
|
||||
});
|
||||
|
||||
// ── searchNominatim (fetch-dependent) ────────────────────────────────────────
|
||||
|
||||
describe('searchNominatim (fetch stubbed)', () => {
|
||||
|
||||
Reference in New Issue
Block a user