305 lines
8.4 KiB
TypeScript
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>
|
|
`;
|
|
}
|
|
}
|