/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * EPSG lookup dialog - search by code or name. * * Uses the local full EPSG index from @ifc-lite/data so search remains stable * and works offline once the bundle is loaded. */ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { Search, Globe, Loader2 } from 'lucide-react'; import { lookupEpsgByCode, searchEpsgIndex, type EpsgIndexEntry, } from '@ifc-lite/data'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; export interface EpsgResult { code: string; name: string; area: string; unit: string; kind?: string; datum?: string; projection?: string; } const RECENT_EPSG_STORAGE_KEY = 'ifc-lite:recent-epsg-codes'; const MAX_RECENT_CODES = 6; const MAX_STARTER_RESULTS = 8; const GLOBAL_DEFAULT_CODES = [ '4326', '3857', '32632', '32633', '27700', '2154', '28992', '2263', ]; const REGIONAL_CODES: Record = { AU: ['7855', '28355'], AT: ['31255', '31256', '31257'], BE: ['31370'], CH: ['2056', '21781'], DE: ['25832', '25833', '5555'], FR: ['2154'], GB: ['27700'], HK: ['2326'], IT: ['6706'], JP: ['3092', '3093', '3094', '3095'], NL: ['28992', '7415'], NZ: ['2193'], SE: ['3006'], SG: ['3414'], US: ['2263', '2227', '26917', '6339'], }; const TIMEZONE_REGION_CODES: Array<{ prefix: string; region: string }> = [ { prefix: 'Europe/Zurich', region: 'CH' }, { prefix: 'Europe/Berlin', region: 'DE' }, { prefix: 'Europe/Vienna', region: 'AT' }, { prefix: 'Europe/London', region: 'GB' }, { prefix: 'Europe/Paris', region: 'FR' }, { prefix: 'Europe/Amsterdam', region: 'NL' }, { prefix: 'Europe/Brussels', region: 'BE' }, { prefix: 'Europe/Rome', region: 'IT' }, { prefix: 'Europe/Stockholm', region: 'SE' }, { prefix: 'America/New_York', region: 'US' }, { prefix: 'America/Los_Angeles', region: 'US' }, { prefix: 'America/Chicago', region: 'US' }, { prefix: 'America/Denver', region: 'US' }, { prefix: 'Asia/Tokyo', region: 'JP' }, { prefix: 'Asia/Hong_Kong', region: 'HK' }, { prefix: 'Asia/Singapore', region: 'SG' }, { prefix: 'Australia/', region: 'AU' }, { prefix: 'Pacific/Auckland', region: 'NZ' }, ]; function toDialogResult(entry: EpsgIndexEntry): EpsgResult { return { code: entry.code, name: entry.name, area: entry.area, unit: entry.unit, kind: entry.kind, datum: entry.datum || undefined, projection: entry.projection || undefined, }; } // ── Bundled offline fallback (common BIM/GIS codes) ──────────────────── const COMMON_CRS: EpsgResult[] = [ { code: '4326', name: 'WGS 84', area: 'World', unit: 'degree', kind: 'Geographic', datum: 'WGS84' }, { code: '3857', name: 'WGS 84 / Pseudo-Mercator', area: 'World', unit: 'metre', kind: 'Projected', datum: 'WGS84' }, { code: '4258', name: 'ETRS89', area: 'Europe', unit: 'degree', kind: 'Geographic', datum: 'ETRS89' }, { code: '25832', name: 'ETRS89 / UTM zone 32N', area: 'Europe 6°-12°E', unit: 'metre', kind: 'Projected', datum: 'ETRS89' }, { code: '25833', name: 'ETRS89 / UTM zone 33N', area: 'Europe 12°-18°E', unit: 'metre', kind: 'Projected', datum: 'ETRS89' }, { code: '27700', name: 'OSGB 1936 / British National Grid', area: 'United Kingdom', unit: 'metre', kind: 'Projected', datum: 'OSGB 1936' }, { code: '2154', name: 'RGF93 v1 / Lambert-93', area: 'France', unit: 'metre', kind: 'Projected', datum: 'RGF93 v1' }, { code: '28992', name: 'Amersfoort / RD New', area: 'Netherlands', unit: 'metre', kind: 'Projected', datum: 'Amersfoort' }, { code: '2263', name: 'NAD83 / New York Long Island (ftUS)', area: 'USA - New York - SPCS - Long Island', unit: 'US survey foot', kind: 'Projected', datum: 'NAD83' }, { code: '26917', name: 'NAD83 / UTM zone 17N', area: 'North America - 84°W to 78°W', unit: 'metre', kind: 'Projected', datum: 'NAD83' }, { code: '32632', name: 'WGS 84 / UTM zone 32N', area: 'World 6°-12°E', unit: 'metre', kind: 'Projected', datum: 'WGS84' }, { code: '32633', name: 'WGS 84 / UTM zone 33N', area: 'World 12°-18°E', unit: 'metre', kind: 'Projected', datum: 'WGS84' }, ]; function readRecentCodes(): string[] { if (typeof window === 'undefined') return []; try { const raw = window.localStorage.getItem(RECENT_EPSG_STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed.filter((value): value is string => typeof value === 'string').slice(0, MAX_RECENT_CODES); } catch { return []; } } function writeRecentCode(code: string): void { if (typeof window === 'undefined') return; try { const deduped = [code, ...readRecentCodes().filter(existing => existing !== code)].slice(0, MAX_RECENT_CODES); window.localStorage.setItem(RECENT_EPSG_STORAGE_KEY, JSON.stringify(deduped)); } catch { // Ignore storage failures. } } function getRegionHints(): string[] { if (typeof window === 'undefined') return []; const timeZoneHints: string[] = []; const languages = navigator.languages?.length ? navigator.languages : [navigator.language]; const languageHints = new Set(); for (const language of languages) { const parts = language.replace('_', '-').split('-'); const region = parts[1]?.toUpperCase(); if (region && region in REGIONAL_CODES) languageHints.add(region); } const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; for (const candidate of TIMEZONE_REGION_CODES) { if (timeZone.startsWith(candidate.prefix)) { timeZoneHints.push(candidate.region); } } const orderedTimeZoneHints = Array.from(new Set(timeZoneHints)); if (orderedTimeZoneHints.length > 0) { return orderedTimeZoneHints; } return Array.from(languageHints); } async function getStarterResults(): Promise { const recentCodes = readRecentCodes(); const regionHints = getRegionHints(); const candidateCodes = [ ...recentCodes, ...regionHints.flatMap(region => REGIONAL_CODES[region] ?? []), ...GLOBAL_DEFAULT_CODES, ]; const seen = new Set(); const dedupedCodes: string[] = []; for (const code of candidateCodes) { if (seen.has(code)) continue; seen.add(code); dedupedCodes.push(code); if (dedupedCodes.length >= MAX_STARTER_RESULTS) break; } const entries = await Promise.all(dedupedCodes.map(code => lookupEpsgByCode(code))); const results = entries .filter((entry): entry is EpsgIndexEntry => Boolean(entry)) .map(entry => toDialogResult(entry)); if (results.length > 0) { return results; } return COMMON_CRS.slice(0, MAX_STARTER_RESULTS); } // ── Dialog component ─────────────────────────────────────────────────── interface EpsgLookupDialogProps { onSelect: (result: EpsgResult) => void; children?: React.ReactNode; } export function EpsgLookupDialog({ onSelect, children }: EpsgLookupDialogProps) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const debounceRef = useRef | null>(null); const abortRef = useRef(null); const resetSearchState = useCallback(() => { if (debounceRef.current) { clearTimeout(debounceRef.current); debounceRef.current = null; } if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; } setQuery(''); setResults([]); setLoading(false); setError(null); }, []); const localIndex = useMemo(() => { return COMMON_CRS.map(crs => ({ ...crs, _s: `${crs.code} ${crs.name} ${crs.area} ${crs.datum ?? ''} ${crs.projection ?? ''}`.toLowerCase(), })); }, []); const search = useCallback(async (searchQuery: string) => { const trimmed = searchQuery.trim(); if (!trimmed) { setResults([]); setError(null); return; } const queryLower = trimmed.toLowerCase(); const isCode = /^\d+$/.test(trimmed); const localMatches = localIndex .filter(c => isCode ? c.code.startsWith(trimmed) : c._s.includes(queryLower)) .slice(0, 10); if (localMatches.length > 0) { setResults(localMatches); } if (abortRef.current) abortRef.current.abort(); const controller = new AbortController(); abortRef.current = controller; setLoading(true); setError(null); try { const resolved = isCode ? await lookupEpsgByCode(trimmed, { prefix: true, limit: 25 }) : await searchEpsgIndex(trimmed, { limit: 25 }); if (controller.signal.aborted) return; const authoritativeMatches = Array.isArray(resolved) ? resolved : resolved ? [resolved] : []; const dedupedResults: EpsgResult[] = []; const seenCodes = new Set(); for (const candidate of [ ...localMatches.map(result => ({ code: result.code, result })), ...authoritativeMatches.map(entry => ({ code: entry.code, result: toDialogResult(entry) })), ]) { if (seenCodes.has(candidate.code)) continue; seenCodes.add(candidate.code); dedupedResults.push(candidate.result); if (dedupedResults.length >= 25) break; } if (dedupedResults.length > 0) { setResults(dedupedResults); setError(null); } else if (localMatches.length === 0) { setResults([]); setError('No coordinate reference systems found'); } } catch (err: unknown) { if (err instanceof Error && err.name === 'AbortError') return; console.error('[EPSG Lookup] Local search failed', err); if (localMatches.length === 0) { setError('Search unavailable'); } } finally { if (!controller.signal.aborted) setLoading(false); } }, [localIndex]); const handleInputChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value; setQuery(value); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => search(value), 250); }, [search]); const handleSelect = useCallback((result: EpsgResult) => { writeRecentCode(result.code); onSelect(result); setOpen(false); resetSearchState(); }, [onSelect, resetSearchState]); useEffect(() => { if (!open || query) return; let cancelled = false; void getStarterResults() .then(starterResults => { if (cancelled) return; setResults(starterResults); setError(null); }) .catch(() => { if (cancelled) return; setResults(COMMON_CRS.slice(0, MAX_STARTER_RESULTS)); setError(null); }); return () => { cancelled = true; }; }, [open, query]); useEffect(() => { return () => { if (debounceRef.current) clearTimeout(debounceRef.current); if (abortRef.current) abortRef.current.abort(); }; }, []); return ( { setOpen(nextOpen); if (!nextOpen) resetSearchState(); }} > {children || ( )} EPSG Lookup Search by code, name, country, or datum
: } className="h-8 text-xs" autoFocus />
{error && (

{error}

)} {results.length > 0 && (
{results.map((result) => ( ))}
)}
); }