Skip to content

Commit 4c3a188

Browse files
authored
feat(search): resolve H3 cell indexes in the place-search box (#1497)
* feat(search): resolve H3 cell indexes in the place-search box Typing an H3 cell index into the Layers panel search box now resolves it locally, with no geocoder round-trip, and jumps to that cell. Both spellings people copy around are accepted: the hexadecimal string (8928308280fffff, optionally 0x-prefixed and in any case) and the unsigned 64-bit integer (617700169958293503). Selecting the result fits the view to the cell rather than flying to a fixed zoom, since a cell spans anything from a continent at resolution 0 to under a square meter at resolution 15, and outlines the cell on the map so the match is visible. The outline is ephemeral, cleared on the next selection, on Clear, and on unmount. Parsing lives in a standalone lib module alongside coordinates.ts so it unit tests in isolation, and unwraps boundary longitudes around the cell center so a cell crossing the antimeridian yields a contiguous ring. Fixes #1492 * i18n: localize the search placeholder's coordinate and H3 hints The English placeholder advertises place, lat/lon, and H3 input, but every other locale still read "Search places...", so the two locally resolved query forms were undiscoverable outside English. Bring all 15 remaining locales in line.
1 parent a3ca1d4 commit 4c3a188

23 files changed

Lines changed: 346 additions & 30 deletions

File tree

apps/geolibre-desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"exifr": "^7.1.3",
5858
"fflate": "^0.8.3",
5959
"gdal3.js": "^2.8.1",
60+
"h3-js": "^4.5.0",
6061
"html2canvas-pro": "^2.3.2",
6162
"i18next": "^26.3.6",
6263
"jspdf": "^4.2.1",

apps/geolibre-desktop/src/components/panels/LayerPanelPlaceSearch.tsx

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ import {
1919
} from "@geolibre/core";
2020
import type { MapController } from "@geolibre/map";
2121
import { Input } from "@geolibre/ui";
22-
import { Loader2, LocateFixed, MapPin, Search, X } from "lucide-react";
22+
import { Hexagon, Loader2, LocateFixed, MapPin, Search, X } from "lucide-react";
2323
import { formatLatLon, parseLatLon } from "../../lib/coordinates";
24+
import { type H3CellMatch, parseH3Cell } from "../../lib/h3-search";
2425

2526
interface LayerPanelPlaceSearchProps {
2627
mapControllerRef: RefObject<MapController | null>;
@@ -32,6 +33,12 @@ const DEBOUNCE_MS = 500;
3233
const MAX_RESULTS = 6;
3334
/** Don't search until the query is at least this many characters. */
3435
const MIN_QUERY_LENGTH = 2;
36+
/** Ephemeral map ids for the outline drawn around a searched H3 cell. */
37+
const H3_SOURCE_ID = "geolibre-h3-search-cell";
38+
const H3_FILL_LAYER_ID = "geolibre-h3-search-cell-fill";
39+
const H3_LINE_LAYER_ID = "geolibre-h3-search-cell-line";
40+
/** Highlight color for the H3 cell, matching the place-search marker. */
41+
const H3_HIGHLIGHT_COLOR = "#ef4444";
3542

3643
type SearchStatus = "idle" | "loading" | "error" | "empty";
3744

@@ -40,6 +47,11 @@ type SearchStatus = "idle" | "loading" | "error" | "empty";
4047
* panel. Forward-geocodes the typed query through the configured provider,
4148
* lists matches in a dropdown above the input, and on selection flies the map
4249
* to the place and drops a marker. Replaces the former advanced-formats note.
50+
*
51+
* Two query forms bypass the geocoder entirely and resolve locally: a lat/lon
52+
* coordinate (see `coordinates.ts`) and an H3 cell index in either spelling
53+
* (see `h3-search.ts`), the latter fitting the view to the cell and outlining
54+
* it on the map.
4355
*/
4456
export function LayerPanelPlaceSearch({
4557
mapControllerRef,
@@ -53,6 +65,9 @@ export function LayerPanelPlaceSearch({
5365
// True when the single result is a parsed lat/lon jump rather than a geocoder
5466
// match, so the row is labeled and iconed as a coordinate instead of a place.
5567
const [isCoordinate, setIsCoordinate] = useState(false);
68+
// Set when the single result is a parsed H3 cell index, both to label the row
69+
// and to supply the outline drawn on selection.
70+
const [h3Cell, setH3Cell] = useState<H3CellMatch | null>(null);
5671
const [open, setOpen] = useState(false);
5772
const [status, setStatus] = useState<SearchStatus>("idle");
5873
const [activeIndex, setActiveIndex] = useState(-1);
@@ -74,13 +89,28 @@ export function LayerPanelPlaceSearch({
7489
return Math.max(DEBOUNCE_MS, geocoderMinIntervalMs(endpoint));
7590
}, [geocodingPrefs]);
7691

92+
/**
93+
* Remove the H3 cell outline from the map, if one is currently drawn. Safe to
94+
* call when the map is gone or was never given the highlight (style reloads
95+
* drop it), so callers never have to track whether it exists.
96+
*/
97+
const clearH3Highlight = useCallback(() => {
98+
const map = mapControllerRef.current?.getMap();
99+
if (!map) return;
100+
for (const layerId of [H3_FILL_LAYER_ID, H3_LINE_LAYER_ID]) {
101+
if (map.getLayer(layerId)) map.removeLayer(layerId);
102+
}
103+
if (map.getSource(H3_SOURCE_ID)) map.removeSource(H3_SOURCE_ID);
104+
}, [mapControllerRef]);
105+
77106
useEffect(
78107
() => () => {
79108
abortRef.current?.abort();
80109
markerRef.current?.remove();
110+
clearH3Highlight();
81111
if (blurTimerRef.current) clearTimeout(blurTimerRef.current);
82112
},
83-
[],
113+
[clearH3Highlight],
84114
);
85115

86116
const runSearch = useCallback(
@@ -89,6 +119,7 @@ export function LayerPanelPlaceSearch({
89119
const controller = new AbortController();
90120
abortRef.current = controller;
91121
setIsCoordinate(false);
122+
setH3Cell(null);
92123
setStatus("loading");
93124
setActiveIndex(-1);
94125
setOpen(true);
@@ -122,10 +153,25 @@ export function LayerPanelPlaceSearch({
122153
setResults([]);
123154
setActiveIndex(-1);
124155
setIsCoordinate(false);
156+
setH3Cell(null);
125157
setStatus("idle");
126158
setOpen(false);
127159
return;
128160
}
161+
// H3 short-circuit: a query that parses as an H3 cell index (hexadecimal or
162+
// unsigned 64-bit integer) resolves locally to that cell's center, so the
163+
// exact cell is used rather than whatever the geocoder makes of the digits.
164+
const cell = parseH3Cell(trimmed);
165+
if (cell) {
166+
abortRef.current?.abort();
167+
setResults([{ lat: cell.lat, lon: cell.lon, displayName: cell.cell, score: null }]);
168+
setActiveIndex(0);
169+
setIsCoordinate(false);
170+
setH3Cell(cell);
171+
setStatus("idle");
172+
setOpen(true);
173+
return;
174+
}
129175
// Coordinate short-circuit: a query that parses as lat/lon (DD, DMS, or DDM)
130176
// becomes a direct "go to coordinate" jump, resolved instantly with no
131177
// geocoder round-trip so the exact point (not the nearest named place) is
@@ -138,6 +184,7 @@ export function LayerPanelPlaceSearch({
138184
]);
139185
setActiveIndex(0);
140186
setIsCoordinate(true);
187+
setH3Cell(null);
141188
setStatus("idle");
142189
setOpen(true);
143190
return;
@@ -151,16 +198,45 @@ export function LayerPanelPlaceSearch({
151198
const handleSelect = useCallback(
152199
(match: GeocodeMatch) => {
153200
const map = mapControllerRef.current?.getMap();
154-
// Drop the previous marker unconditionally so it is never orphaned when
155-
// the map is briefly unavailable (mount/teardown/headless).
201+
// Drop the previous marker and cell outline unconditionally so neither is
202+
// ever orphaned when the map is briefly unavailable (mount/teardown/
203+
// headless) or when the next result is of a different kind.
156204
markerRef.current?.remove();
157205
markerRef.current = null;
158-
if (map) {
206+
clearH3Highlight();
207+
if (map && h3Cell) {
208+
// An H3 cell spans anything from a continent (resolution 0) to under a
209+
// square meter (resolution 15), so frame the cell itself rather than
210+
// flying to a fixed zoom, and outline it so the match is visible.
211+
map.addSource(H3_SOURCE_ID, {
212+
type: "geojson",
213+
data: {
214+
type: "Feature",
215+
properties: { h3: h3Cell.cell, resolution: h3Cell.resolution },
216+
geometry: { type: "Polygon", coordinates: [h3Cell.boundary] },
217+
},
218+
});
219+
map.addLayer({
220+
id: H3_FILL_LAYER_ID,
221+
type: "fill",
222+
source: H3_SOURCE_ID,
223+
paint: { "fill-color": H3_HIGHLIGHT_COLOR, "fill-opacity": 0.15 },
224+
});
225+
map.addLayer({
226+
id: H3_LINE_LAYER_ID,
227+
type: "line",
228+
source: H3_SOURCE_ID,
229+
paint: { "line-color": H3_HIGHLIGHT_COLOR, "line-width": 2 },
230+
});
231+
const bounds = new maplibregl.LngLatBounds();
232+
for (const position of h3Cell.boundary) bounds.extend(position);
233+
map.fitBounds(bounds, { padding: 60 });
234+
} else if (map) {
159235
map.flyTo({
160236
center: [match.lon, match.lat],
161237
zoom: Math.max(map.getZoom(), 12),
162238
});
163-
markerRef.current = new maplibregl.Marker({ color: "#ef4444" })
239+
markerRef.current = new maplibregl.Marker({ color: H3_HIGHLIGHT_COLOR })
164240
.setLngLat([match.lon, match.lat])
165241
.addTo(map);
166242
}
@@ -169,23 +245,26 @@ export function LayerPanelPlaceSearch({
169245
setResults([]);
170246
setActiveIndex(-1);
171247
setIsCoordinate(false);
248+
setH3Cell(null);
172249
setStatus("idle");
173250
setOpen(false);
174251
},
175-
[mapControllerRef],
252+
[clearH3Highlight, h3Cell, mapControllerRef],
176253
);
177254

178255
const handleClear = useCallback(() => {
179256
abortRef.current?.abort();
180257
markerRef.current?.remove();
181258
markerRef.current = null;
259+
clearH3Highlight();
182260
setQuery("");
183261
setResults([]);
184262
setActiveIndex(-1);
185263
setIsCoordinate(false);
264+
setH3Cell(null);
186265
setStatus("idle");
187266
setOpen(false);
188-
}, []);
267+
}, [clearH3Highlight]);
189268

190269
const showResults = status === "idle" && results.length > 0;
191270

@@ -226,17 +305,24 @@ export function LayerPanelPlaceSearch({
226305
}}
227306
onMouseEnter={() => setActiveIndex(index)}
228307
>
229-
{isCoordinate ? (
308+
{h3Cell ? (
309+
<Hexagon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
310+
) : isCoordinate ? (
230311
<LocateFixed className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
231312
) : (
232313
<MapPin className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
233314
)}
234315
<span className="line-clamp-2">
235-
{isCoordinate
236-
? t("layers.searchPlacesGoToCoordinate", {
237-
coordinate: match.displayName,
316+
{h3Cell
317+
? t("layers.searchPlacesGoToH3Cell", {
318+
cell: h3Cell.cell,
319+
resolution: h3Cell.resolution,
238320
})
239-
: match.displayName}
321+
: isCoordinate
322+
? t("layers.searchPlacesGoToCoordinate", {
323+
coordinate: match.displayName,
324+
})
325+
: match.displayName}
240326
</span>
241327
</button>
242328
</li>

apps/geolibre-desktop/src/i18n/locales/ar.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4251,12 +4251,13 @@
42514251
"saveEditsPostgisNoConnection": "لا يتوفر اتصال PostgreSQL لهذه الطبقة. أعد الاتصال من إضافة البيانات > PostgreSQL، ثم حاول مرة أخرى.",
42524252
"typeBasemap": "خريطة أساس",
42534253
"searchPlaces": "البحث عن أماكن",
4254-
"searchPlacesPlaceholder": "ابحث عن أماكن...",
4254+
"searchPlacesPlaceholder": "ابحث عن مكان أو إحداثيات أو خلية H3...",
42554255
"searchPlacesSearching": "جارٍ البحث...",
42564256
"searchPlacesNoResults": "لم يُعثر على أماكن",
42574257
"searchPlacesError": "فشل البحث. حاول مرة أخرى.",
42584258
"searchPlacesClear": "مسح البحث",
42594259
"searchPlacesGoToCoordinate": "الانتقال إلى {{coordinate}}",
4260+
"searchPlacesGoToH3Cell": "الانتقال إلى خلية H3 ‏{{cell}} (الدقة {{resolution}})",
42604261
"background": "الخلفية",
42614262
"backgroundCannotReorder": "لا يمكن إعادة ترتيب الخلفية",
42624263
"hideBackground": "إخفاء الخلفية",

apps/geolibre-desktop/src/i18n/locales/de.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4020,12 +4020,13 @@
40204020
"saveEditsPostgisNoConnection": "Für diese Ebene ist keine PostgreSQL-Verbindung verfügbar. Stellen Sie die Verbindung unter Daten hinzufügen > PostgreSQL erneut her und versuchen Sie es dann noch einmal.",
40214021
"typeBasemap": "Basiskarte",
40224022
"searchPlaces": "Orte suchen",
4023-
"searchPlacesPlaceholder": "Orte suchen...",
4023+
"searchPlacesPlaceholder": "Orte, Koordinaten oder H3-Zelle suchen...",
40244024
"searchPlacesSearching": "Suche läuft...",
40254025
"searchPlacesNoResults": "Keine Orte gefunden",
40264026
"searchPlacesError": "Suche fehlgeschlagen. Versuchen Sie es erneut.",
40274027
"searchPlacesClear": "Suche löschen",
40284028
"searchPlacesGoToCoordinate": "Zu {{coordinate}} springen",
4029+
"searchPlacesGoToH3Cell": "Zur H3-Zelle {{cell}} springen (Res. {{resolution}})",
40294030
"background": "Hintergrund",
40304031
"backgroundCannotReorder": "Der Hintergrund kann nicht neu angeordnet werden",
40314032
"hideBackground": "Hintergrund ausblenden",

apps/geolibre-desktop/src/i18n/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4255,12 +4255,13 @@
42554255
"saveEditsPostgisNoConnection": "No PostgreSQL connection is available for this layer. Reconnect in Add Data > PostgreSQL, then try again.",
42564256
"typeBasemap": "basemap",
42574257
"searchPlaces": "Search places",
4258-
"searchPlacesPlaceholder": "Search places or lat, lon...",
4258+
"searchPlacesPlaceholder": "Search places, lat, lon, or H3 cell...",
42594259
"searchPlacesSearching": "Searching...",
42604260
"searchPlacesNoResults": "No places found",
42614261
"searchPlacesError": "Search failed. Try again.",
42624262
"searchPlacesClear": "Clear search",
42634263
"searchPlacesGoToCoordinate": "Go to {{coordinate}}",
4264+
"searchPlacesGoToH3Cell": "Go to H3 cell {{cell}} (res {{resolution}})",
42644265
"background": "Background",
42654266
"backgroundCannotReorder": "Background cannot be reordered",
42664267
"hideBackground": "Hide background",

apps/geolibre-desktop/src/i18n/locales/es.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4031,12 +4031,13 @@
40314031
"saveEditsPostgisNoConnection": "No hay ninguna conexión de PostgreSQL disponible para esta capa. Vuelva a conectar en Añadir datos > PostgreSQL, e inténtelo de nuevo.",
40324032
"typeBasemap": "mapa base",
40334033
"searchPlaces": "Buscar lugares",
4034-
"searchPlacesPlaceholder": "Buscar lugares...",
4034+
"searchPlacesPlaceholder": "Buscar lugares, coordenadas o celda H3...",
40354035
"searchPlacesSearching": "Buscando...",
40364036
"searchPlacesNoResults": "No se encontraron lugares",
40374037
"searchPlacesError": "Error en la búsqueda. Inténtelo de nuevo.",
40384038
"searchPlacesClear": "Borrar búsqueda",
40394039
"searchPlacesGoToCoordinate": "Ir a {{coordinate}}",
4040+
"searchPlacesGoToH3Cell": "Ir a la celda H3 {{cell}} (res. {{resolution}})",
40404041
"background": "Fondo",
40414042
"backgroundCannotReorder": "El fondo no se puede reordenar",
40424043
"hideBackground": "Ocultar fondo",

apps/geolibre-desktop/src/i18n/locales/fr.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4020,12 +4020,13 @@
40204020
"saveEditsPostgisNoConnection": "Aucune connexion PostgreSQL n'est disponible pour cette couche. Reconnectez-vous dans Ajouter des données > PostgreSQL, puis réessayez.",
40214021
"typeBasemap": "fond de carte",
40224022
"searchPlaces": "Rechercher des lieux",
4023-
"searchPlacesPlaceholder": "Rechercher des lieux...",
4023+
"searchPlacesPlaceholder": "Rechercher un lieu, des coordonnées ou une cellule H3...",
40244024
"searchPlacesSearching": "Recherche...",
40254025
"searchPlacesNoResults": "Aucun lieu trouvé",
40264026
"searchPlacesError": "Échec de la recherche. Réessayez.",
40274027
"searchPlacesClear": "Effacer la recherche",
40284028
"searchPlacesGoToCoordinate": "Aller à {{coordinate}}",
4029+
"searchPlacesGoToH3Cell": "Aller à la cellule H3 {{cell}} (rés. {{resolution}})",
40294030
"background": "Arrière-plan",
40304031
"backgroundCannotReorder": "L'arrière-plan ne peut pas être réorganisé",
40314032
"hideBackground": "Masquer l'arrière-plan",

apps/geolibre-desktop/src/i18n/locales/hi.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4020,12 +4020,13 @@
40204020
"saveEditsPostgisNoConnection": "इस लेयर के लिए कोई PostgreSQL कनेक्शन उपलब्ध नहीं है। Add Data > PostgreSQL में पुनः कनेक्ट करें, फिर पुनः प्रयास करें।",
40214021
"typeBasemap": "बेसमैप",
40224022
"searchPlaces": "स्थान खोजें",
4023-
"searchPlacesPlaceholder": "स्थान खोजें...",
4023+
"searchPlacesPlaceholder": "स्थान, निर्देशांक या H3 सेल खोजें...",
40244024
"searchPlacesSearching": "खोजा जा रहा है...",
40254025
"searchPlacesNoResults": "कोई स्थान नहीं मिला",
40264026
"searchPlacesError": "खोज विफल रही। पुनः प्रयास करें।",
40274027
"searchPlacesClear": "खोज साफ़ करें",
40284028
"searchPlacesGoToCoordinate": "{{coordinate}} पर जाएँ",
4029+
"searchPlacesGoToH3Cell": "H3 सेल {{cell}} पर जाएँ (रेज़ॉल्यूशन {{resolution}})",
40294030
"background": "पृष्ठभूमि",
40304031
"backgroundCannotReorder": "पृष्ठभूमि का क्रम नहीं बदला जा सकता",
40314032
"hideBackground": "पृष्ठभूमि छिपाएं",

apps/geolibre-desktop/src/i18n/locales/id.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3962,12 +3962,13 @@
39623962
"saveEditsPostgisNoConnection": "Tidak ada koneksi PostgreSQL yang tersedia untuk layer ini. Hubungkan kembali di Tambah Data > PostgreSQL, lalu coba lagi.",
39633963
"typeBasemap": "peta dasar",
39643964
"searchPlaces": "Cari tempat",
3965-
"searchPlacesPlaceholder": "Cari tempat...",
3965+
"searchPlacesPlaceholder": "Cari tempat, koordinat, atau sel H3...",
39663966
"searchPlacesSearching": "Mencari...",
39673967
"searchPlacesNoResults": "Tidak ada tempat yang ditemukan",
39683968
"searchPlacesError": "Pencarian gagal. Coba lagi.",
39693969
"searchPlacesClear": "Bersihkan pencarian",
39703970
"searchPlacesGoToCoordinate": "Menuju {{coordinate}}",
3971+
"searchPlacesGoToH3Cell": "Menuju sel H3 {{cell}} (res {{resolution}})",
39713972
"background": "Latar belakang",
39723973
"backgroundCannotReorder": "Latar belakang tidak dapat diurutkan ulang",
39733974
"hideBackground": "Sembunyikan latar belakang",

apps/geolibre-desktop/src/i18n/locales/it.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4020,12 +4020,13 @@
40204020
"saveEditsPostgisNoConnection": "Nessuna connessione PostgreSQL disponibile per questo livello. Riconnetti da Aggiungi dati > PostgreSQL, quindi riprova.",
40214021
"typeBasemap": "mappa di base",
40224022
"searchPlaces": "Cerca luoghi",
4023-
"searchPlacesPlaceholder": "Cerca luoghi...",
4023+
"searchPlacesPlaceholder": "Cerca luoghi, coordinate o celle H3...",
40244024
"searchPlacesSearching": "Ricerca in corso...",
40254025
"searchPlacesNoResults": "Nessun luogo trovato",
40264026
"searchPlacesError": "Ricerca non riuscita. Riprova.",
40274027
"searchPlacesClear": "Cancella ricerca",
40284028
"searchPlacesGoToCoordinate": "Vai a {{coordinate}}",
4029+
"searchPlacesGoToH3Cell": "Vai alla cella H3 {{cell}} (ris. {{resolution}})",
40294030
"background": "Sfondo",
40304031
"backgroundCannotReorder": "Lo sfondo non può essere riordinato",
40314032
"hideBackground": "Nascondi sfondo",

0 commit comments

Comments
 (0)