initial
This commit is contained in:
303
ts_web/elements/00group-map/dees-geo-map/geo-map.search.ts
Normal file
303
ts_web/elements/00group-map/dees-geo-map/geo-map.search.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
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
|
||||
*/
|
||||
public render(containerRef?: Element | null): TemplateResult {
|
||||
return html`
|
||||
<div class="search-container">
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user