Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/business-location-intent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openmapx/core": patch
---

Require complete address-word evidence for exact autocomplete location intent while retaining weaker prefix matches and explicit remote destinations.
64 changes: 64 additions & 0 deletions apps/web/src/components/search/SearchBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Profiler } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getMapObstructionInsets, publishMapObstruction } from "@/lib/mapObstructions";
import { act, createFakeMap, createQueryWrapper, fireEvent, render, screen, waitFor } from "@/test";
import businessLocationFixture from "../../../../../packages/core/src/utils/__tests__/fixtures/business-location-intent.json";

vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl());

Expand Down Expand Up @@ -136,11 +137,13 @@ beforeEach(() => {
useMediaQueryMock.mockReset().mockReturnValue(false);
flyToMock.mockReset();
fakeMap.state.center = { lng: 0, lat: 0 };
fakeMap.state.zoom = 10;
launchExploreFromPlace.mockReset();
launchExploreTextSearch.mockReset();
launchTextSearch.mockReset();
useSearchStore.getState().reset();
useDirectionsStore.getState().close(); // SearchBar returns null while directions open
useCategorySearchStore.getState().clearCategory();
useCategorySearchStore.setState({ anchor: null, exploreBoxOpen: false, activeCategory: null });
usePlaceStore.setState({ selectedPlace: null });
useMapStore.setState({ userLocation: null });
Expand Down Expand Up @@ -466,6 +469,67 @@ describe("SearchBar", () => {
expect(useRecentSearchStore.getState().entries).toEqual(["Berlin Hbf"]);
});

it("shows Berlin first for weak remote address-prefix evidence and plain Enter searches the area", async () => {
fakeMap.state.center = { lng: 13.416, lat: 52.5194 };
fakeMap.state.zoom = 15;
const places = businessLocationFixture as AutocompleteResult[];
useAutocompleteMock.mockReturnValue({ data: places, isFetching: false });
// Forward retrieval is independently remote-first; it must not rescue a weak POI match.
useGeocodingMock.mockReturnValue({
data: places.map((p) => ({ ...p, label: p.sublabel, confidence: 0.78 })),
});
useSettingsStore.setState({ aiSearchEnabled: false });
useSearchStore.setState({ query: "MediaMarkt Alexa", isFocused: true });
renderBar();
const input = screen.getByLabelText("search.ariaLabel");
await screen.findByRole("option", { name: /Grunerstraße 20/ });
const options = screen.getAllByRole("option");
expect(options[0]).toHaveTextContent("Grunerstraße 20");
expect(options[1]).toHaveTextContent("Pr Willem Alexander Prom");
fireEvent.submit(input.closest("form") as HTMLFormElement);
expect(usePlaceStore.getState().selectedPlace).toBeNull();
expect(flyToMock).not.toHaveBeenCalled();
expect(launchTextSearch).toHaveBeenCalledWith(fakeMap.map, "MediaMarkt Alexa");
});

it.each([
["MediaMarkt Alexa", "maptiler:poi.15422100", "Grunerstraße 20"],
["MediaMarkt Rijswijk", "maptiler:poi.15539884", "Pr Willem Alexander Prom"],
])(
"selects the displayed first row for %s with ArrowDown + Enter",
async (query, id, address) => {
fakeMap.state.center = { lng: 13.416, lat: 52.5194 };
fakeMap.state.zoom = 15;
useAutocompleteMock.mockReturnValue({ data: businessLocationFixture, isFetching: false });
renderBar();
const input = screen.getByLabelText("search.ariaLabel");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: query } });
await screen.findByRole("option", { name: /Grunerstraße 20/ });
expect(screen.getAllByRole("option")[0]).toHaveTextContent(address);
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
expect(usePlaceStore.getState().selectedPlace?.address).toContain(address);
expect(usePlaceStore.getState().selectedPlace?.ids.maptiler).toBe(
id.replace("maptiler:", ""),
);
},
);

it("plain Enter still opens the explicitly named remote business city", async () => {
fakeMap.state.center = { lng: 13.416, lat: 52.5194 };
fakeMap.state.zoom = 15;
useAutocompleteMock.mockReturnValue({ data: businessLocationFixture, isFetching: false });
useSearchStore.setState({ query: "MediaMarkt Rijswijk", isFocused: true });
renderBar();
const input = screen.getByLabelText("search.ariaLabel");
await screen.findByRole("option", { name: /Pr Willem Alexander Prom/ });
fireEvent.submit(input.closest("form") as HTMLFormElement);
await waitFor(() =>
expect(usePlaceStore.getState().selectedPlace?.address).toContain("Rijswijk"),
);
});

it("records an explicitly submitted query without storing every keystroke", async () => {
renderBar();
const input = screen.getByLabelText("search.ariaLabel");
Expand Down
11 changes: 11 additions & 0 deletions docs/docs/features/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ Paris, while a word that names a kind of place nearby ("vegan", "döner") or a
chain's branches ("aldi") search what you can currently see on the map. When
several equally known places far away share the name ("springfield"), the list
stays open with the first one highlighted, and a second Enter takes it.

A complete name followed by complete normalized address words (for example,
"MediaMarkt Rijswijk") supplies explicit location evidence, even far from the
map. A partial address word remains a weaker autocomplete match: "Alexa" in a
street named "Alexander" does not establish exact remote business intent.
Ordinary name prefixes, explicit aliases and official codes keep their existing
confidence. When the returned rows do not confidently name the query, plain
Enter follows the existing natural-language or visible-area search path; an
explicit dropdown choice still opens that row. This can make the dropdown order
differ from the server's candidate order without changing candidate retrieval.

Coordinates and Plus Codes are detected client side and resolved without a
round trip to a geocoder at all.

Expand Down
102 changes: 102 additions & 0 deletions packages/core/src/utils/__tests__/business-location-intent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import type { AutocompleteResult } from "../../types/geocoding";
import { queryNamesLocation, textMatchScore } from "../searchSuggestion";
import { enterAction, rankAutocompleteRows } from "../suggestionRanking";
import captured from "./fixtures/business-location-intent.json";

// Adapted hosted autocomplete, captured 2026-10-07. Not raw MapTiler data.
const places = captured as AutocompleteResult[];
const [rijswijk, berlin] = places;
const context = {
query: "MediaMarkt Alexa",
proximity: [13.416, 52.5194] as [number, number],
zoom: 15,
};

describe("business location evidence", () => {
it("retains a partial street match without treating it as an exact destination", () => {
expect(textMatchScore(rijswijk, context.query)).toBeLessThan(0.75);
expect(textMatchScore(rijswijk, context.query)).toBeGreaterThan(
textMatchScore(berlin, context.query),
);
expect(queryNamesLocation(rijswijk, context.query)).toBe(false);
});

it("ranks Berlin above the weak remote prefix despite Rijswijk-first retrieval", () => {
expect(places[0].id).toBe("maptiler:poi.15539884");
const rows = rankAutocompleteRows({ places }, context);
expect(rows).toHaveLength(places.length);
expect(rows[0].id).toBe("maptiler:poi.15422100");
expect(rows[1].id).toBe("maptiler:poi.15539884");
expect(enterAction(rows, context)).toEqual({ kind: "search", weak: true });
});

it.each([
"MediaMarkt Rijswijk",
"MediaMarkt Alexander",
"MediaMarkt Pr Willem Alexander Prom 69-83",
"MediaMarkt 2284 DJ Rijswijk",
])("retains complete remote city/address intent: %s", (query) => {
const remote = { ...context, query };
expect(queryNamesLocation(rijswijk, query)).toBe(true);
expect(rankAutocompleteRows({ places }, remote)[0].id).toBe(rijswijk.id);
expect(enterAction(rankAutocompleteRows({ places }, remote), remote)).toMatchObject({
kind: "open",
row: { id: rijswijk.id },
});
});

it.each(["MediaMarkt Rijs", "MediaMarkt Willem Alexa", "MediaMarkt 69-8"])(
"does not establish location intent from incomplete or repeated name words: %s",
(query) => {
expect(queryNamesLocation(rijswijk, query)).toBe(false);
expect(textMatchScore(rijswijk, query)).toBeLessThan(0.75);
},
);

it("does not use the repeated display name as address corroboration", () => {
expect(queryNamesLocation(rijswijk, "MediaMarkt MediaMarkt")).toBe(false);
});

it("preserves normalized numbered street evidence", () => {
const row: AutocompleteResult = {
...rijswijk,
sublabel: "MediaMarkt, Friedrichstraße 100, Berlin",
};
expect(queryNamesLocation(row, "MediaMarkt Friedrichstr. 100")).toBe(true);
expect(textMatchScore(row, "MediaMarkt Friedrichstr. 100")).toBe(1);
});

it("keeps primary-name autocomplete confident without declaring an address", () => {
const row: AutocompleteResult = { ...rijswijk, label: "MediaMarkt Alexanderplatz" };
expect(textMatchScore(row, context.query)).toBe(0.8);
expect(queryNamesLocation(row, context.query)).toBe(false);
});

it.each(["explicit_alias", "authoritative_code"] as const)(
"preserves exact remote %s evidence",
(kind) => {
const row: AutocompleteResult = {
...rijswijk,
searchMatch: { kind, value: "MMA", normalized: "mma" },
};
const remote = { ...context, query: "MMA" };
expect(
enterAction(rankAutocompleteRows({ places: [berlin, row] }, remote), remote),
).toMatchObject({ kind: "open", row: { id: rijswijk.id } });
},
);

it("preserves exact station abbreviation and same-name proximity ranking", () => {
const station: AutocompleteResult = {
...rijswijk,
label: "Neuss Hbf",
sublabel: "Neuss Hbf, Neuss",
rawCategory: "railway/station",
};
expect(textMatchScore(station, "Neuss Hauptbahnhof")).toBe(1);
expect(rankAutocompleteRows({ places }, { ...context, query: "MediaMarkt" })[0].id).toBe(
berlin.id,
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
[
{
"id": "maptiler:poi.15539884",
"label": "MediaMarkt",
"sublabel": "MediaMarkt, Pr Willem Alexander Prom 69-83, 2284 DJ Rijswijk, Netherlands",
"coordinates": [4.316869229078293, 52.03636822992996],
"type": "poi",
"rawCategory": "electronics",
"provider": "geocoding-maptiler"
},
{
"id": "maptiler:poi.15422100",
"label": "MediaMarkt",
"sublabel": "MediaMarkt, Grunerstraße 20, 10179 Berlin, Germany",
"coordinates": [13.414790779352188, 52.51986159027454],
"type": "poi",
"rawCategory": "electronics",
"provider": "geocoding-maptiler"
},
{
"id": "maptiler:poi.15388462",
"label": "MediaMarkt",
"sublabel": "MediaMarkt, Äußere Schneeberger Straße 95, 08056 Zwickau, Germany",
"coordinates": [12.499412186443806, 50.70416170071806],
"type": "poi",
"rawCategory": "electronics",
"provider": "geocoding-maptiler"
},
{
"id": "maptiler:poi.15513302",
"label": "MediaMarkt",
"sublabel": "MediaMarkt, Waldershofer Straße 10, 95615 Marktredwitz, Germany",
"coordinates": [12.077845813448675, 49.99956173454102],
"type": "poi",
"rawCategory": "electronics",
"provider": "geocoding-maptiler"
},
{
"id": "maptiler:poi.27301831",
"label": "MediaMarkt",
"sublabel": "MediaMarkt, Schloßplatz 8, 26122 Oldenburg, Germany",
"coordinates": [8.216576837003231, 53.13856416583732],
"type": "poi",
"rawCategory": "electronics",
"provider": "geocoding-maptiler"
},
{
"id": "maptiler:poi.31290179",
"label": "MediaMarkt",
"sublabel": "MediaMarkt, Posthalterweg 15, 26129 Oldenburg, Germany",
"coordinates": [8.172727230560895, 53.16099834226386],
"type": "poi",
"rawCategory": "electronics",
"provider": "geocoding-maptiler"
}
]
25 changes: 19 additions & 6 deletions packages/core/src/utils/searchSuggestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,10 +323,22 @@ function nameWithoutItsTown(item: AutocompleteResult): string | undefined {
/**
* Whether the query is the row's whole name followed by words of its address:
* "10115 berlin" for the postcode 10115 in Berlin, "paris france".
* Only complete address words corroborate exact location intent. Prefixes
* remain autocomplete evidence, scored separately below, rather than turning
* "Alexa" in a street called "Alexander" into an explicit remote destination.
*/
function isNameWithItsPlace(key: string, label: string, context: string): boolean {
if (!key.startsWith(`${label} `)) return false;
return everyTokenStartsAWord(words(key.slice(label.length + 1)), words(context));
function isNameWithItsPlace(key: string, label: string, address: string): boolean {
if (!label || !key.startsWith(`${label} `)) return false;
// Provider sublabels often repeat the primary name before the address; that
// repetition supplies no independent evidence of where the place is.
const place =
address === label
? ""
: address.startsWith(`${label} `)
? address.slice(label.length + 1)
: address;
const placeWords = words(place);
return words(key.slice(label.length + 1)).every((token) => placeWords.includes(token));
}

function exactMatchScore(kind: SearchMatchKind): number {
Expand Down Expand Up @@ -401,7 +413,8 @@ export function textMatchScore(item: AutocompleteResult, query: string): number
const key = matchKey(query);
const label = matchKey(item.label);
const context = matchKey(`${item.label} ${item.sublabel ?? ""}`);
if (label === key || isNameWithItsPlace(key, label, context)) return TEXT_SCORE.exact;
if (label === key || isNameWithItsPlace(key, label, matchKey(item.sublabel ?? "")))
return TEXT_SCORE.exact;
// A category's name in the singular names it as fully: "museum" for Museums.
if (item.type === "category" && label === `${key}s`) return TEXT_SCORE.exact;
if (label.startsWith(key)) return TEXT_SCORE.prefix;
Expand Down Expand Up @@ -485,15 +498,15 @@ export function isSameDestination(a: AutocompleteResult, b: AutocompleteResult):

/**
* Whether the query names where the row is, beyond its name: a house number
* or postcode that is part of its name ("hauptstraße 5", "10115"), or a word
* or postcode that is part of its name ("hauptstraße 5", "10115"), or complete words
* of its address typed after the name ("10115 berlin", "paris france").
*/
export function queryNamesLocation(item: AutocompleteResult, query: string): boolean {
const key = matchKey(query);
const label = matchKey(item.label);
const labelWords = words(label);
if (words(key).some((token) => /\d/u.test(token) && labelWords.includes(token))) return true;
return isNameWithItsPlace(key, label, matchKey(`${item.label} ${item.sublabel ?? ""}`));
return isNameWithItsPlace(key, label, matchKey(item.sublabel ?? ""));
}

/**
Expand Down
Loading