Skip to content

Commit dc05e24

Browse files
committed
feat(search): text-search results panel with category-aware filters
Submitting free text in the top search bar (Enter / search icon) opens a viewport-scoped results panel — reusing the category results list, map markers, filter bar and the "search this area" / auto-refresh mechanism. Backed by a new Overpass name-search route so results carry openingHoursInfo and osmTags (filterable, "open now"), unlike the thin geocoder path it replaces. The filter bar infers the dominant category of the results and shows that category's facets (e.g. "McDonald's" → restaurants → food filters). Precise location matches (address/street/region) and transit stops still navigate straight to the place; named-POI queries open the list. The category chip bar hides while the text panel is open.
1 parent d5f69eb commit dc05e24

19 files changed

Lines changed: 316 additions & 117 deletions

apps/web/src/components/map/SearchInAreaChip.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export function SearchInAreaChip() {
2626
setSearchBbox: setCategorySearchBbox,
2727
setMapMoved: setCategoryMapMoved,
2828
} = useCategorySearchStore();
29+
const mode = useCategorySearchStore((s) => s.mode);
30+
const anchor = useCategorySearchStore((s) => s.anchor);
31+
const isViewportText = mode === "text" && anchor === null;
2932
const { selectedPlace } = usePlaceStore();
3033
const activeSource = useDataSourceStore((s) => s.activeSource);
3134
const dsMapMoved = useDataSourceStore((s) => s.mapMoved);
@@ -40,10 +43,12 @@ export function SearchInAreaChip() {
4043
return sourcesData.sources.find((s) => s.id === activeSource)?.minZoom ?? 0;
4144
}, [activeSource, sourcesData]);
4245

43-
const floatingCardOpen = activeCategory !== null && selectedPlace !== null;
46+
const floatingCardOpen = (activeCategory !== null || isViewportText) && selectedPlace !== null;
4447

45-
// Show when either a category or data source is active and map has moved
46-
const showForCategory = activeCategory !== null && categoryMoved && !autoRefresh;
48+
// Show when a category search, a viewport text search, or a data source is
49+
// active and the map has moved.
50+
const showForCategory =
51+
(activeCategory !== null || isViewportText) && categoryMoved && !autoRefresh;
4752
const showForDataSource = activeSource !== null && dsMapMoved && viewportZoom >= activeMinZoom;
4853

4954
if (!showForCategory && !showForDataSource) return null;

apps/web/src/components/panels/category/CategoryResultsContent.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,12 @@ export function CategoryResultsContent() {
206206
setHoveredCategoryPlaceId,
207207
} = useCategorySearchStore();
208208
const anchor = useCategorySearchStore((s) => s.anchor);
209+
const mode = useCategorySearchStore((s) => s.mode);
209210
const autoRefresh = useCategorySearchStore((s) => s.autoRefresh);
210211
const setAutoRefresh = useCategorySearchStore((s) => s.setAutoRefresh);
212+
// Viewport text search (top search bar, no anchor) behaves like a category:
213+
// panning offers "search this area" + the auto-refresh toggle.
214+
const isViewportText = mode === "text" && anchor === null;
211215
const { setSelectedPlace } = usePlaceStore();
212216
const { flyTo, mapRef, mapReady } = useMap();
213217

@@ -251,7 +255,7 @@ export function CategoryResultsContent() {
251255
// manual "Search this area" chip.
252256
useEffect(() => {
253257
const map = mapRef.current;
254-
if (!map || !mapReady || !activeCategory) return;
258+
if (!map || !mapReady || (!activeCategory && !isViewportText)) return;
255259

256260
const onMoveEnd = (e: maplibregl.MapLibreEvent) => {
257261
// Ignore app-driven camera moves (flyTo on result select, fitBounds on
@@ -274,7 +278,7 @@ export function CategoryResultsContent() {
274278
return () => {
275279
map.off("moveend", onMoveEnd);
276280
};
277-
}, [mapRef, mapReady, activeCategory, autoRefresh, setSearchBbox, setMapMoved]);
281+
}, [mapRef, mapReady, activeCategory, isViewportText, autoRefresh, setSearchBbox, setMapMoved]);
278282

279283
const handleSelectPlace = (place: CategoryPlace) => {
280284
flyTo(place.coordinates, 17);
@@ -292,7 +296,7 @@ export function CategoryResultsContent() {
292296

293297
return (
294298
<Box sx={{ flex: 1, overflowY: "auto", pt: { xs: 2, sm: "72px" } }}>
295-
{(anchor || activeCategory) && (
299+
{(anchor || activeCategory || isViewportText) && (
296300
<Box
297301
sx={{
298302
px: 2,
@@ -304,7 +308,7 @@ export function CategoryResultsContent() {
304308
}}
305309
>
306310
{anchor && <ExploreTravelTimeControl />}
307-
{activeCategory && (
311+
{(activeCategory || isViewportText) && (
308312
<FormControlLabel
309313
control={
310314
<Switch

apps/web/src/components/search/CategoryChips.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ function SvgIcon({ path, size = 16 }: { path: string; size?: number }) {
3535

3636
export function CategoryChips() {
3737
const { activeCategory, setActiveCategory, clearCategory } = useCategorySearchStore();
38+
const textSearchActive = useCategorySearchStore((s) => s.mode === "text");
3839
const { setQuery } = useSearchStore();
3940
const { isOpen: directionsOpen } = useDirectionsStore();
4041
const zoom = useMapStore((s) => s.zoom);
@@ -105,7 +106,7 @@ export function CategoryChips() {
105106
return () => el.removeEventListener("scroll", updateScrollState);
106107
}, [updateScrollState]);
107108

108-
const hidden = directionsOpen || activeCategory || activeSource;
109+
const hidden = directionsOpen || activeCategory || activeSource || textSearchActive;
109110
const zoomedOut = zoom < 9;
110111

111112
if (hidden) return null;

apps/web/src/components/search/CategoryFilterBar.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
useCategoryFacetStore,
2323
useCategorySearchStore,
2424
useDataSourceStore,
25-
useFilteredCategoryResults,
25+
useExploreResults,
2626
useOpeningHoursStore,
2727
} from "@openmapx/core";
2828
import { useTranslations } from "next-intl";
@@ -131,11 +131,15 @@ export function CategoryFilterBar() {
131131
const facetSelections = useCategoryFacetStore((s) => s.selections);
132132
const toggleFacet = useCategoryFacetStore((s) => s.toggleFacet);
133133
const activeSource = useDataSourceStore((s) => s.activeSource);
134-
const { rawResults } = useFilteredCategoryResults();
134+
const { rawResults, dominantCategory } = useExploreResults();
135+
// In text mode there is no active category chip — reuse the facets of the
136+
// category that the results predominantly belong to (e.g. "McDonald's" →
137+
// restaurants), so a text search shows the same filters as that category.
138+
const effectiveCategory = activeCategory ?? dominantCategory;
135139

136140
const panelFacets = useMemo(
137-
() => facetsForCategory(activeCategory).filter((f) => f.placement === "panel"),
138-
[activeCategory],
141+
() => facetsForCategory(effectiveCategory).filter((f) => f.placement === "panel"),
142+
[effectiveCategory],
139143
);
140144
const cuisineOpts = useMemo(() => getCuisineOptions(rawResults ?? []), [rawResults]);
141145
const activePanelCount = panelFacets.filter(
@@ -196,7 +200,7 @@ export function CategoryFilterBar() {
196200
);
197201
}
198202

199-
if (!activeCategory || !HOURS_FILTER_CATEGORY_IDS.has(activeCategory)) return null;
203+
if (!effectiveCategory || !HOURS_FILTER_CATEGORY_IDS.has(effectiveCategory)) return null;
200204

201205
const isFiltered = openingHoursFilter !== "any";
202206
const label = chipLabel(openingHoursFilter, openAtDay, openAtHour, t);

apps/web/src/components/search/SearchBar.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,11 @@ import { useLocale, useTranslations } from "next-intl";
6464
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6565
import { AccountAvatarButton } from "@/components/auth/AccountAvatarButton";
6666
import { SEARCH_INPUT_ID } from "@/components/command-palette/constants";
67-
import { launchExploreFromPlace, launchExploreTextSearch } from "@/lib/launchExplore";
67+
import {
68+
launchExploreFromPlace,
69+
launchExploreTextSearch,
70+
launchTextSearch,
71+
} from "@/lib/launchExplore";
6872
import { useMap } from "@/lib/MapContext";
6973
import { TEAL } from "@/lib/theme";
7074
import { AutocompleteDropdown } from "./AutocompleteDropdown";
@@ -568,8 +572,12 @@ export function SearchBar() {
568572
handleSelect(syntheticResult);
569573
return;
570574
}
575+
// A precise location match (address/street/region) or a transit stop
576+
// navigates straight to that place, like Google. A named-POI query (or no
577+
// geocode match) opens the viewport-scoped text results panel instead.
571578
const first = geocodeData?.[0];
572-
if (first) {
579+
const isTransit = Boolean(first?.rawCategory && isTransitRawCategory(first.rawCategory));
580+
if (first && (first.type !== "poi" || isTransit)) {
573581
flyTo(first.coordinates, 15);
574582
const firstPlace = createPlace({
575583
...idsFromPrimaryOrCoords(first.id, first.coordinates),
@@ -579,7 +587,7 @@ export function SearchBar() {
579587
category: first.type,
580588
rawCategory: first.rawCategory,
581589
});
582-
if (first.rawCategory && isTransitRawCategory(first.rawCategory)) {
590+
if (isTransit) {
583591
void tryOpenTransitStop(first.coordinates, first.label).then((found) => {
584592
if (!found) {
585593
setSelectedPlace(firstPlace);
@@ -590,6 +598,10 @@ export function SearchBar() {
590598
setSelectedPlace(firstPlace);
591599
useSidebarStore.getState().openSidebar(PANEL.PLACE);
592600
}
601+
return;
602+
}
603+
if (q.trim().length > 0) {
604+
launchTextSearch(mapRef.current, q);
593605
}
594606
};
595607

apps/web/src/lib/launchExplore.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,30 @@ export function launchExploreTextSearch(
7979
useSearchStore.getState().setQuery(trimmed);
8080
useSidebarStore.getState().openSidebar(PANEL.CATEGORY);
8181
}
82+
83+
/**
84+
* Launch a free-text POI search over the current map viewport (not anchored to
85+
* a place): snapshot the current bounds as the search bbox and run text mode.
86+
* Used when the user submits the top search bar. Panning the map then offers
87+
* "search this area" (see CategoryResultsContent).
88+
*/
89+
export function launchTextSearch(map: maplibregl.Map | null, query: string): void {
90+
const trimmed = query.trim();
91+
if (trimmed.length === 0 || !map) return;
92+
const store = useCategorySearchStore.getState();
93+
const b = map.getBounds();
94+
95+
useDataSourceStore.getState().setActiveSource(null);
96+
store.setAnchor(null);
97+
store.setSearchBbox({
98+
west: b.getWest(),
99+
south: b.getSouth(),
100+
east: b.getEast(),
101+
north: b.getNorth(),
102+
});
103+
store.setMapMoved(false);
104+
store.setExploreText(trimmed);
105+
store.closeExploreBox();
106+
useSearchStore.getState().setQuery(trimmed);
107+
useSidebarStore.getState().openSidebar(PANEL.CATEGORY);
108+
}

integrations/poi-overpass/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
CATEGORY_FILTERS,
44
searchByCategory,
55
searchByOsmTags,
6+
searchByText,
67
setOverpassUrl,
78
} from "@openmapx/core";
89
import type { IntegrationContext } from "@openmapx/integration-framework";
@@ -31,6 +32,9 @@ const overpassProvider: PoiSearchProvider = {
3132
if (!filters || filters.length === 0) return [];
3233
return searchByCategory(filters, bbox);
3334
},
35+
async searchText(query: string, bbox: BoundingBox): Promise<PoiSearchResult[]> {
36+
return searchByText(query, bbox);
37+
},
3438
};
3539

3640
export function setup(ctx: IntegrationContext): void {

integrations/poi-search/index.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,55 @@ export function setup(ctx: IntegrationContext): void {
5656
}
5757
});
5858

59+
ctx.registerRoute("GET", "/text", async (req, reply) => {
60+
const { q, south, west, north, east, lang } = req.query;
61+
62+
if (!q || q.trim().length < 2) {
63+
reply.send({ results: [], partial: false });
64+
return;
65+
}
66+
67+
const bbox = {
68+
south: Number.parseFloat(south),
69+
west: Number.parseFloat(west),
70+
north: Number.parseFloat(north),
71+
east: Number.parseFloat(east),
72+
};
73+
for (const [key, val] of Object.entries(bbox)) {
74+
if (!Number.isFinite(val)) {
75+
reply.status(400).send({ error: `Invalid bbox parameter: ${key}` });
76+
return;
77+
}
78+
}
79+
80+
const bboxRounded = {
81+
east: round(bbox.east, 2),
82+
north: round(bbox.north, 2),
83+
south: round(bbox.south, 2),
84+
west: round(bbox.west, 2),
85+
};
86+
const cacheKey = `text:${q.trim().toLowerCase()}:${bboxRounded.south},${bboxRounded.west},${bboxRounded.north},${bboxRounded.east}`;
87+
88+
try {
89+
const result = await ctx.cache.withCache(cacheKey, 300, () =>
90+
orchestrator.searchText(q, bbox, { lang }),
91+
);
92+
reply.header("Cache-Control", "public, max-age=300");
93+
reply.send(result);
94+
} catch (err) {
95+
if (err instanceof OverpassTimeoutError) {
96+
reply.status(422).send({ error: "area_too_large" });
97+
return;
98+
}
99+
const e = err as { statusCode?: number; message: string };
100+
if (e.statusCode === 400) {
101+
reply.status(400).send({ error: e.message });
102+
return;
103+
}
104+
throw err;
105+
}
106+
});
107+
59108
ctx.registerRoute("GET", "/preset-suggest", async (req, reply) => {
60109
const { q, lang, limit } = req.query as { q?: string; lang?: string; limit?: string };
61110

integrations/poi-search/orchestrator.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,5 +89,41 @@ export function createPoiSearchOrchestrator(ctx: IntegrationContext) {
8989
}
9090
}
9191

92-
return { search, getProviders };
92+
async function searchText(
93+
query: unknown,
94+
bbox: BoundingBox,
95+
options?: { lang?: string },
96+
): Promise<{ results: PoiSearchResult[]; partial: boolean }> {
97+
if (typeof query !== "string" || query.trim().length === 0) {
98+
throw Object.assign(new Error("Missing or empty query"), { statusCode: 400 });
99+
}
100+
const provider = getProviders().find((p) => typeof p.searchText === "function");
101+
if (!provider?.searchText) {
102+
throw Object.assign(new Error("No text-search provider available"), { statusCode: 400 });
103+
}
104+
105+
let currentBbox = bbox;
106+
for (let attempt = 0; ; attempt++) {
107+
try {
108+
const results = await provider.searchText(query, currentBbox, { lang: options?.lang });
109+
for (const r of results) {
110+
if (r.openingHours && !r.openingHoursInfo) {
111+
r.openingHoursInfo = buildOpeningHoursInfo(r.openingHours, {
112+
lat: r.coordinates[1],
113+
lon: r.coordinates[0],
114+
});
115+
}
116+
}
117+
return { results, partial: attempt > 0 };
118+
} catch (err) {
119+
if (err instanceof OverpassTimeoutError && attempt < MAX_SHRINK_RETRIES) {
120+
currentBbox = shrinkBbox(currentBbox, SHRINK_FACTOR);
121+
continue;
122+
}
123+
throw err;
124+
}
125+
}
126+
}
127+
128+
return { search, searchText, getProviders };
93129
}

packages/core/src/api/endpoints.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const API_ENDPOINTS = {
1212
traffic: "/api/traffic",
1313
streetViewImages: "/api/integrations/street-view-mapillary/streetview/images",
1414
categorySearch: "/api/integrations/poi-search/search",
15+
textSearch: "/api/integrations/poi-search/text",
1516
presetSuggest: "/api/integrations/poi-search/preset-suggest",
1617
chipTranslations: "/api/integrations/poi-search/chip-translations",
1718
fuelPricesDetail: "/api/fuel-prices/detail",

0 commit comments

Comments
 (0)