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 | 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 { 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 = { 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 */ public render(containerRef?: Element | null): TemplateResult { return html`
${renderIcon('search')}
this.handleInput(e)} @keydown=${(e: KeyboardEvent) => this.handleKeydown(e)} @focus=${() => this.handleFocus()} /> ${this.isSearching ? html`
${renderIcon('spinner')}
` : this.query ? html` ` : ''}
${this.isOpen ? html`
${this.results.length > 0 ? this.results.map((result, index) => html`
this.selectResult(result)} @mouseenter=${() => { this.highlightedIndex = index; this.callbacks.onRequestUpdate(); }} > ${result.display_name} ${result.type}
`) : this.query.length >= this.minQueryLength && !this.isSearching ? html`
No results found
` : ''}
` : ''}
`; } }