Files
dees-catalog-geo/ts_web/elements/00group-map/dees-geo-map/geo-map.search.ts

305 lines
8.4 KiB
TypeScript

import { html, type TemplateResult } from '@design.estate/dees-element';
import { renderIcon } from './geo-map.icons.js';
/**
* Result from Nominatim geocoding API
*/
export interface INominatimResult {
place_id: number;
licence: string;
osm_type: string;
osm_id: number;
boundingbox: [string, string, string, string]; // [south, north, west, east]
lat: string;
lon: string;
display_name: string;
class: string;
type: string;
importance: number;
}
/**
* Event fired when an address is selected from search results
*/
export interface IAddressSelectedEvent {
address: string;
coordinates: [number, number]; // [lng, lat]
boundingBox: [number, number, number, number]; // [south, north, west, east]
placeId: string;
type: string;
}
/**
* Configuration for SearchController
*/
export interface ISearchControllerConfig {
placeholder?: string;
debounceMs?: number;
minQueryLength?: number;
maxResults?: number;
}
/**
* Callback interface for SearchController events
*/
export interface ISearchControllerCallbacks {
onResultSelected: (result: INominatimResult, coordinates: [number, number], zoom: number) => void;
onRequestUpdate: () => void;
}
/**
* Reusable search controller for Nominatim geocoding
* Can be used for standalone search or within navigation inputs
*/
export class SearchController {
// State
public query: string = '';
public results: INominatimResult[] = [];
public isOpen: boolean = false;
public highlightedIndex: number = -1;
public isSearching: boolean = false;
// Config
private placeholder: string;
private debounceMs: number;
private minQueryLength: number;
private maxResults: number;
// Internal
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private callbacks: ISearchControllerCallbacks;
private boundClickOutsideHandler: ((e: MouseEvent) => void) | null = null;
constructor(config: ISearchControllerConfig, callbacks: ISearchControllerCallbacks) {
this.placeholder = config.placeholder ?? 'Search address...';
this.debounceMs = config.debounceMs ?? 500;
this.minQueryLength = config.minQueryLength ?? 3;
this.maxResults = config.maxResults ?? 5;
this.callbacks = callbacks;
}
/**
* Search Nominatim API for addresses
*/
public async search(query: string): Promise<INominatimResult[]> {
if (query.length < this.minQueryLength) return [];
const params = new URLSearchParams({
q: query,
format: 'json',
limit: String(this.maxResults),
addressdetails: '1',
});
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
headers: {
'User-Agent': 'dees-geo-map/1.0',
},
});
if (!response.ok) return [];
return response.json();
}
/**
* Handle input event from search input
*/
public handleInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.query = input.value;
this.highlightedIndex = -1;
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.query.length < this.minQueryLength) {
this.results = [];
this.isOpen = false;
this.isSearching = false;
this.callbacks.onRequestUpdate();
return;
}
this.isSearching = true;
this.callbacks.onRequestUpdate();
this.debounceTimer = setTimeout(async () => {
const results = await this.search(this.query);
this.results = results;
this.isOpen = results.length > 0 || this.query.length >= this.minQueryLength;
this.isSearching = false;
this.callbacks.onRequestUpdate();
}, this.debounceMs);
}
/**
* Handle keyboard navigation in search results
*/
public handleKeydown(event: KeyboardEvent): void {
if (!this.isOpen) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.highlightedIndex = Math.min(
this.highlightedIndex + 1,
this.results.length - 1
);
this.callbacks.onRequestUpdate();
break;
case 'ArrowUp':
event.preventDefault();
this.highlightedIndex = Math.max(this.highlightedIndex - 1, -1);
this.callbacks.onRequestUpdate();
break;
case 'Enter':
event.preventDefault();
if (this.highlightedIndex >= 0 && this.results[this.highlightedIndex]) {
this.selectResult(this.results[this.highlightedIndex]);
}
break;
case 'Escape':
event.preventDefault();
this.close();
break;
}
}
/**
* Select a search result
*/
public selectResult(result: INominatimResult): void {
const lng = parseFloat(result.lon);
const lat = parseFloat(result.lat);
const coordinates: [number, number] = [lng, lat];
const zoom = this.calculateZoomForResult(result);
this.callbacks.onResultSelected(result, coordinates, zoom);
this.close();
}
/**
* Handle focus on search input
*/
public handleFocus(): void {
if (this.results.length > 0 || this.query.length >= this.minQueryLength) {
this.isOpen = true;
this.callbacks.onRequestUpdate();
}
}
/**
* Clear search state
*/
public clear(): void {
this.close();
}
/**
* Close search dropdown
*/
public close(): void {
this.isOpen = false;
this.results = [];
this.query = '';
this.highlightedIndex = -1;
this.callbacks.onRequestUpdate();
}
/**
* Set query without triggering search (for external updates)
*/
public setQuery(query: string): void {
this.query = query;
this.results = [];
this.isOpen = false;
this.callbacks.onRequestUpdate();
}
/**
* Calculate appropriate zoom level based on result type
*/
public calculateZoomForResult(result: INominatimResult): number {
const type = result.type;
const osmClass = result.class;
// Zoom levels based on place type
if (osmClass === 'boundary' && type === 'administrative') {
// Use importance to determine administrative level
if (result.importance > 0.8) return 5; // Country
if (result.importance > 0.6) return 7; // State/Region
if (result.importance > 0.4) return 10; // County
return 12; // City/Town
}
const zoomByType: Record<string, number> = {
country: 5,
state: 7,
region: 7,
county: 10,
city: 12,
town: 13,
village: 14,
suburb: 14,
neighbourhood: 15,
street: 16,
road: 16,
house: 18,
building: 18,
};
return zoomByType[type] ?? 15;
}
/**
* Render the search component
* @param extraClass - Optional CSS class to add to the container for positioning
*/
public render(extraClass?: string): TemplateResult {
return html`
<div class="search-container ${extraClass || ''}">
<div class="search-input-wrapper">
<div class="search-icon">
${renderIcon('search')}
</div>
<input
type="text"
class="search-input"
placeholder="${this.placeholder}"
.value=${this.query}
@input=${(e: Event) => this.handleInput(e)}
@keydown=${(e: KeyboardEvent) => this.handleKeydown(e)}
@focus=${() => this.handleFocus()}
/>
${this.isSearching ? html`
<div class="search-spinner">
${renderIcon('spinner')}
</div>
` : this.query ? html`
<button class="search-clear" @click=${() => this.clear()} title="Clear">
${renderIcon('close')}
</button>
` : ''}
</div>
${this.isOpen ? html`
<div class="search-results">
${this.results.length > 0 ? this.results.map((result, index) => html`
<div
class="search-result ${index === this.highlightedIndex ? 'highlighted' : ''}"
@click=${() => this.selectResult(result)}
@mouseenter=${() => { this.highlightedIndex = index; this.callbacks.onRequestUpdate(); }}
>
<span class="search-result-name">${result.display_name}</span>
<span class="search-result-type">${result.type}</span>
</div>
`) : this.query.length >= this.minQueryLength && !this.isSearching ? html`
<div class="search-no-results">No results found</div>
` : ''}
</div>
` : ''}
</div>
`;
}
}