@ifc-lite/viewer 1.17.2 → 1.17.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +30 -29
- package/.turbo/turbo-typecheck.log +1 -42
- package/CHANGELOG.md +9 -0
- package/dist/assets/arrow-DJf2ErbF.js +20 -0
- package/dist/assets/basketViewActivator-aojwdomq.js +1 -0
- package/dist/assets/bcf-D5-QWGO9.js +281 -0
- package/dist/assets/{browser-BDShTXzi.js → browser-CKs-FY1P.js} +1 -1
- package/dist/assets/drawing-2d-gWfpdfYe.js +257 -0
- package/dist/assets/epsg-index.generated-BjJrt_0S.js +1 -0
- package/dist/assets/exporters-C_6J153K.js +79896 -0
- package/dist/assets/geometry.worker-Nz9_YIqh.js +1 -0
- package/dist/assets/ids-B4jTqB1O.js +1 -0
- package/dist/assets/{ifc-lite_bg-FNRmpSvM.wasm → ifc-lite_bg-eSkBTizQ.wasm} +0 -0
- package/dist/assets/index-jhBr1wbn.js +101666 -0
- package/dist/assets/index-pbE7itQS.css +1 -0
- package/dist/assets/lens-CSASnhAL.js +1 -0
- package/dist/assets/maplibre-gl-BpvwNKKy.js +811 -0
- package/dist/assets/{native-bridge-Crsb7TKz.js → native-bridge-DSIyEYXG.js} +6 -4
- package/dist/assets/{arrow2-bb-jcVEo.js → parquet-CEXmQNRO.js} +2 -2
- package/dist/assets/sandbox-B79eavQ3.js +5933 -0
- package/dist/assets/server-client-D3bUPJJc.js +626 -0
- package/dist/assets/wasm-bridge-B0J07fZZ.js +1 -0
- package/dist/assets/zip-B-jFFAGa.js +12 -0
- package/dist/index.html +11 -2
- package/package.json +24 -19
- package/src/components/viewer/ExportChangesButton.tsx +18 -3
- package/src/components/viewer/ExportDialog.tsx +16 -3
- package/src/components/viewer/HierarchyPanel.tsx +6 -6
- package/src/components/viewer/PropertiesPanel.tsx +96 -60
- package/src/components/viewer/Section2DPanel.tsx +3 -2
- package/src/components/viewer/ViewportContainer.tsx +5 -4
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +2 -1
- package/src/components/viewer/properties/EpsgLookupDialog.tsx +418 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +591 -0
- package/src/components/viewer/properties/LocationMap.tsx +289 -0
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +3 -70
- package/src/hooks/bcfIdLookup.ts +13 -11
- package/src/hooks/ids/idsColorSystem.ts +3 -8
- package/src/hooks/useIDS.ts +31 -16
- package/src/hooks/useIfcFederation.ts +2 -2
- package/src/lib/geo/kmz-exporter.ts +112 -0
- package/src/lib/geo/reproject.ts +244 -0
- package/src/lib/lens/adapter.ts +3 -1
- package/src/main.tsx +1 -0
- package/src/sdk/adapters/export-adapter.ts +14 -1
- package/src/sdk/adapters/viewer-adapter.ts +5 -9
- package/src/sdk/adapters/visibility-adapter.ts +6 -9
- package/src/store/basketVisibleSet.ts +3 -4
- package/src/store/globalId.ts +79 -0
- package/src/store/index.ts +1 -0
- package/src/store/slices/mutationSlice.ts +178 -0
- package/src/store/slices/pinboardSlice.ts +4 -8
- package/vite.config.ts +17 -0
- package/dist/assets/Arrow.dom-BhOg9lpn.js +0 -20
- package/dist/assets/arrow2_bg-BlXl-cSQ.js +0 -1
- package/dist/assets/basketViewActivator-BRG5DBmM.js +0 -1
- package/dist/assets/geometry.worker-kgiT_Qhh.js +0 -1
- package/dist/assets/index-B1Ecw4AU.js +0 -189756
- package/dist/assets/index-Ba4eoTe7.css +0 -1
- package/dist/assets/index-CrgYBjTn.js +0 -229
- package/dist/assets/module-6F3E5H7Y-tx0BadV3.js +0 -6
- package/dist/assets/wasm-bridge-mJUhb7uk.js +0 -1
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* EPSG lookup dialog - search by code or name.
|
|
7
|
+
*
|
|
8
|
+
* Uses the local full EPSG index from @ifc-lite/data so search remains stable
|
|
9
|
+
* and works offline once the bundle is loaded.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
|
13
|
+
import { Search, Globe, Loader2 } from 'lucide-react';
|
|
14
|
+
import {
|
|
15
|
+
lookupEpsgByCode,
|
|
16
|
+
searchEpsgIndex,
|
|
17
|
+
type EpsgIndexEntry,
|
|
18
|
+
} from '@ifc-lite/data';
|
|
19
|
+
import {
|
|
20
|
+
Dialog,
|
|
21
|
+
DialogContent,
|
|
22
|
+
DialogHeader,
|
|
23
|
+
DialogTitle,
|
|
24
|
+
DialogTrigger,
|
|
25
|
+
DialogDescription,
|
|
26
|
+
} from '@/components/ui/dialog';
|
|
27
|
+
import { Input } from '@/components/ui/input';
|
|
28
|
+
|
|
29
|
+
export interface EpsgResult {
|
|
30
|
+
code: string;
|
|
31
|
+
name: string;
|
|
32
|
+
area: string;
|
|
33
|
+
unit: string;
|
|
34
|
+
kind?: string;
|
|
35
|
+
datum?: string;
|
|
36
|
+
projection?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const RECENT_EPSG_STORAGE_KEY = 'ifc-lite:recent-epsg-codes';
|
|
40
|
+
const MAX_RECENT_CODES = 6;
|
|
41
|
+
const MAX_STARTER_RESULTS = 8;
|
|
42
|
+
|
|
43
|
+
const GLOBAL_DEFAULT_CODES = [
|
|
44
|
+
'4326',
|
|
45
|
+
'3857',
|
|
46
|
+
'32632',
|
|
47
|
+
'32633',
|
|
48
|
+
'27700',
|
|
49
|
+
'2154',
|
|
50
|
+
'28992',
|
|
51
|
+
'2263',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const REGIONAL_CODES: Record<string, string[]> = {
|
|
55
|
+
AU: ['7855', '28355'],
|
|
56
|
+
AT: ['31255', '31256', '31257'],
|
|
57
|
+
BE: ['31370'],
|
|
58
|
+
CH: ['2056', '21781'],
|
|
59
|
+
DE: ['25832', '25833', '5555'],
|
|
60
|
+
FR: ['2154'],
|
|
61
|
+
GB: ['27700'],
|
|
62
|
+
HK: ['2326'],
|
|
63
|
+
IT: ['6706'],
|
|
64
|
+
JP: ['3092', '3093', '3094', '3095'],
|
|
65
|
+
NL: ['28992', '7415'],
|
|
66
|
+
NZ: ['2193'],
|
|
67
|
+
SE: ['3006'],
|
|
68
|
+
SG: ['3414'],
|
|
69
|
+
US: ['2263', '2227', '26917', '6339'],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const TIMEZONE_REGION_CODES: Array<{ prefix: string; region: string }> = [
|
|
73
|
+
{ prefix: 'Europe/Zurich', region: 'CH' },
|
|
74
|
+
{ prefix: 'Europe/Berlin', region: 'DE' },
|
|
75
|
+
{ prefix: 'Europe/Vienna', region: 'AT' },
|
|
76
|
+
{ prefix: 'Europe/London', region: 'GB' },
|
|
77
|
+
{ prefix: 'Europe/Paris', region: 'FR' },
|
|
78
|
+
{ prefix: 'Europe/Amsterdam', region: 'NL' },
|
|
79
|
+
{ prefix: 'Europe/Brussels', region: 'BE' },
|
|
80
|
+
{ prefix: 'Europe/Rome', region: 'IT' },
|
|
81
|
+
{ prefix: 'Europe/Stockholm', region: 'SE' },
|
|
82
|
+
{ prefix: 'America/New_York', region: 'US' },
|
|
83
|
+
{ prefix: 'America/Los_Angeles', region: 'US' },
|
|
84
|
+
{ prefix: 'America/Chicago', region: 'US' },
|
|
85
|
+
{ prefix: 'America/Denver', region: 'US' },
|
|
86
|
+
{ prefix: 'Asia/Tokyo', region: 'JP' },
|
|
87
|
+
{ prefix: 'Asia/Hong_Kong', region: 'HK' },
|
|
88
|
+
{ prefix: 'Asia/Singapore', region: 'SG' },
|
|
89
|
+
{ prefix: 'Australia/', region: 'AU' },
|
|
90
|
+
{ prefix: 'Pacific/Auckland', region: 'NZ' },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
function toDialogResult(entry: EpsgIndexEntry): EpsgResult {
|
|
94
|
+
return {
|
|
95
|
+
code: entry.code,
|
|
96
|
+
name: entry.name,
|
|
97
|
+
area: entry.area,
|
|
98
|
+
unit: entry.unit,
|
|
99
|
+
kind: entry.kind,
|
|
100
|
+
datum: entry.datum || undefined,
|
|
101
|
+
projection: entry.projection || undefined,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Bundled offline fallback (common BIM/GIS codes) ────────────────────
|
|
106
|
+
|
|
107
|
+
const COMMON_CRS: EpsgResult[] = [
|
|
108
|
+
{ code: '4326', name: 'WGS 84', area: 'World', unit: 'degree', kind: 'Geographic', datum: 'WGS84' },
|
|
109
|
+
{ code: '3857', name: 'WGS 84 / Pseudo-Mercator', area: 'World', unit: 'metre', kind: 'Projected', datum: 'WGS84' },
|
|
110
|
+
{ code: '4258', name: 'ETRS89', area: 'Europe', unit: 'degree', kind: 'Geographic', datum: 'ETRS89' },
|
|
111
|
+
{ code: '25832', name: 'ETRS89 / UTM zone 32N', area: 'Europe 6°-12°E', unit: 'metre', kind: 'Projected', datum: 'ETRS89' },
|
|
112
|
+
{ code: '25833', name: 'ETRS89 / UTM zone 33N', area: 'Europe 12°-18°E', unit: 'metre', kind: 'Projected', datum: 'ETRS89' },
|
|
113
|
+
{ code: '27700', name: 'OSGB 1936 / British National Grid', area: 'United Kingdom', unit: 'metre', kind: 'Projected', datum: 'OSGB 1936' },
|
|
114
|
+
{ code: '2154', name: 'RGF93 v1 / Lambert-93', area: 'France', unit: 'metre', kind: 'Projected', datum: 'RGF93 v1' },
|
|
115
|
+
{ code: '28992', name: 'Amersfoort / RD New', area: 'Netherlands', unit: 'metre', kind: 'Projected', datum: 'Amersfoort' },
|
|
116
|
+
{ code: '2263', name: 'NAD83 / New York Long Island (ftUS)', area: 'USA - New York - SPCS - Long Island', unit: 'US survey foot', kind: 'Projected', datum: 'NAD83' },
|
|
117
|
+
{ code: '26917', name: 'NAD83 / UTM zone 17N', area: 'North America - 84°W to 78°W', unit: 'metre', kind: 'Projected', datum: 'NAD83' },
|
|
118
|
+
{ code: '32632', name: 'WGS 84 / UTM zone 32N', area: 'World 6°-12°E', unit: 'metre', kind: 'Projected', datum: 'WGS84' },
|
|
119
|
+
{ code: '32633', name: 'WGS 84 / UTM zone 33N', area: 'World 12°-18°E', unit: 'metre', kind: 'Projected', datum: 'WGS84' },
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
function readRecentCodes(): string[] {
|
|
123
|
+
if (typeof window === 'undefined') return [];
|
|
124
|
+
try {
|
|
125
|
+
const raw = window.localStorage.getItem(RECENT_EPSG_STORAGE_KEY);
|
|
126
|
+
if (!raw) return [];
|
|
127
|
+
const parsed = JSON.parse(raw);
|
|
128
|
+
if (!Array.isArray(parsed)) return [];
|
|
129
|
+
return parsed.filter((value): value is string => typeof value === 'string').slice(0, MAX_RECENT_CODES);
|
|
130
|
+
} catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function writeRecentCode(code: string): void {
|
|
136
|
+
if (typeof window === 'undefined') return;
|
|
137
|
+
try {
|
|
138
|
+
const deduped = [code, ...readRecentCodes().filter(existing => existing !== code)].slice(0, MAX_RECENT_CODES);
|
|
139
|
+
window.localStorage.setItem(RECENT_EPSG_STORAGE_KEY, JSON.stringify(deduped));
|
|
140
|
+
} catch {
|
|
141
|
+
// Ignore storage failures.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getRegionHints(): string[] {
|
|
146
|
+
if (typeof window === 'undefined') return [];
|
|
147
|
+
|
|
148
|
+
const timeZoneHints: string[] = [];
|
|
149
|
+
const languages = navigator.languages?.length ? navigator.languages : [navigator.language];
|
|
150
|
+
const languageHints = new Set<string>();
|
|
151
|
+
|
|
152
|
+
for (const language of languages) {
|
|
153
|
+
const parts = language.replace('_', '-').split('-');
|
|
154
|
+
const region = parts[1]?.toUpperCase();
|
|
155
|
+
if (region && region in REGIONAL_CODES) languageHints.add(region);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
159
|
+
for (const candidate of TIMEZONE_REGION_CODES) {
|
|
160
|
+
if (timeZone.startsWith(candidate.prefix)) {
|
|
161
|
+
timeZoneHints.push(candidate.region);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const orderedTimeZoneHints = Array.from(new Set(timeZoneHints));
|
|
166
|
+
if (orderedTimeZoneHints.length > 0) {
|
|
167
|
+
return orderedTimeZoneHints;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return Array.from(languageHints);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function getStarterResults(): Promise<EpsgResult[]> {
|
|
174
|
+
const recentCodes = readRecentCodes();
|
|
175
|
+
const regionHints = getRegionHints();
|
|
176
|
+
const candidateCodes = [
|
|
177
|
+
...recentCodes,
|
|
178
|
+
...regionHints.flatMap(region => REGIONAL_CODES[region] ?? []),
|
|
179
|
+
...GLOBAL_DEFAULT_CODES,
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
const seen = new Set<string>();
|
|
183
|
+
const dedupedCodes: string[] = [];
|
|
184
|
+
|
|
185
|
+
for (const code of candidateCodes) {
|
|
186
|
+
if (seen.has(code)) continue;
|
|
187
|
+
seen.add(code);
|
|
188
|
+
dedupedCodes.push(code);
|
|
189
|
+
if (dedupedCodes.length >= MAX_STARTER_RESULTS) break;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const entries = await Promise.all(dedupedCodes.map(code => lookupEpsgByCode(code)));
|
|
193
|
+
const results = entries
|
|
194
|
+
.filter((entry): entry is EpsgIndexEntry => Boolean(entry))
|
|
195
|
+
.map(entry => toDialogResult(entry));
|
|
196
|
+
|
|
197
|
+
if (results.length > 0) {
|
|
198
|
+
return results;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return COMMON_CRS.slice(0, MAX_STARTER_RESULTS);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Dialog component ───────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
interface EpsgLookupDialogProps {
|
|
207
|
+
onSelect: (result: EpsgResult) => void;
|
|
208
|
+
children?: React.ReactNode;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function EpsgLookupDialog({ onSelect, children }: EpsgLookupDialogProps) {
|
|
212
|
+
const [open, setOpen] = useState(false);
|
|
213
|
+
const [query, setQuery] = useState('');
|
|
214
|
+
const [results, setResults] = useState<EpsgResult[]>([]);
|
|
215
|
+
const [loading, setLoading] = useState(false);
|
|
216
|
+
const [error, setError] = useState<string | null>(null);
|
|
217
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
218
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
219
|
+
|
|
220
|
+
const resetSearchState = useCallback(() => {
|
|
221
|
+
if (debounceRef.current) {
|
|
222
|
+
clearTimeout(debounceRef.current);
|
|
223
|
+
debounceRef.current = null;
|
|
224
|
+
}
|
|
225
|
+
if (abortRef.current) {
|
|
226
|
+
abortRef.current.abort();
|
|
227
|
+
abortRef.current = null;
|
|
228
|
+
}
|
|
229
|
+
setQuery('');
|
|
230
|
+
setResults([]);
|
|
231
|
+
setLoading(false);
|
|
232
|
+
setError(null);
|
|
233
|
+
}, []);
|
|
234
|
+
|
|
235
|
+
const localIndex = useMemo(() => {
|
|
236
|
+
return COMMON_CRS.map(crs => ({
|
|
237
|
+
...crs,
|
|
238
|
+
_s: `${crs.code} ${crs.name} ${crs.area} ${crs.datum ?? ''} ${crs.projection ?? ''}`.toLowerCase(),
|
|
239
|
+
}));
|
|
240
|
+
}, []);
|
|
241
|
+
|
|
242
|
+
const search = useCallback(async (searchQuery: string) => {
|
|
243
|
+
const trimmed = searchQuery.trim();
|
|
244
|
+
if (!trimmed) {
|
|
245
|
+
setResults([]);
|
|
246
|
+
setError(null);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const queryLower = trimmed.toLowerCase();
|
|
251
|
+
const isCode = /^\d+$/.test(trimmed);
|
|
252
|
+
const localMatches = localIndex
|
|
253
|
+
.filter(c => isCode ? c.code.startsWith(trimmed) : c._s.includes(queryLower))
|
|
254
|
+
.slice(0, 10);
|
|
255
|
+
|
|
256
|
+
if (localMatches.length > 0) {
|
|
257
|
+
setResults(localMatches);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (abortRef.current) abortRef.current.abort();
|
|
261
|
+
const controller = new AbortController();
|
|
262
|
+
abortRef.current = controller;
|
|
263
|
+
setLoading(true);
|
|
264
|
+
setError(null);
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const resolved = isCode
|
|
268
|
+
? await lookupEpsgByCode(trimmed, { prefix: true, limit: 25 })
|
|
269
|
+
: await searchEpsgIndex(trimmed, { limit: 25 });
|
|
270
|
+
if (controller.signal.aborted) return;
|
|
271
|
+
|
|
272
|
+
const authoritativeMatches = Array.isArray(resolved) ? resolved : resolved ? [resolved] : [];
|
|
273
|
+
const dedupedResults: EpsgResult[] = [];
|
|
274
|
+
const seenCodes = new Set<string>();
|
|
275
|
+
for (const candidate of [
|
|
276
|
+
...localMatches.map(result => ({ code: result.code, result })),
|
|
277
|
+
...authoritativeMatches.map(entry => ({ code: entry.code, result: toDialogResult(entry) })),
|
|
278
|
+
]) {
|
|
279
|
+
if (seenCodes.has(candidate.code)) continue;
|
|
280
|
+
seenCodes.add(candidate.code);
|
|
281
|
+
dedupedResults.push(candidate.result);
|
|
282
|
+
if (dedupedResults.length >= 25) break;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (dedupedResults.length > 0) {
|
|
286
|
+
setResults(dedupedResults);
|
|
287
|
+
setError(null);
|
|
288
|
+
} else if (localMatches.length === 0) {
|
|
289
|
+
setResults([]);
|
|
290
|
+
setError('No coordinate reference systems found');
|
|
291
|
+
}
|
|
292
|
+
} catch (err: unknown) {
|
|
293
|
+
if (err instanceof Error && err.name === 'AbortError') return;
|
|
294
|
+
console.error('[EPSG Lookup] Local search failed', err);
|
|
295
|
+
if (localMatches.length === 0) {
|
|
296
|
+
setError('Search unavailable');
|
|
297
|
+
}
|
|
298
|
+
} finally {
|
|
299
|
+
if (!controller.signal.aborted) setLoading(false);
|
|
300
|
+
}
|
|
301
|
+
}, [localIndex]);
|
|
302
|
+
|
|
303
|
+
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
304
|
+
const value = e.target.value;
|
|
305
|
+
setQuery(value);
|
|
306
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
307
|
+
debounceRef.current = setTimeout(() => search(value), 250);
|
|
308
|
+
}, [search]);
|
|
309
|
+
|
|
310
|
+
const handleSelect = useCallback((result: EpsgResult) => {
|
|
311
|
+
writeRecentCode(result.code);
|
|
312
|
+
onSelect(result);
|
|
313
|
+
setOpen(false);
|
|
314
|
+
resetSearchState();
|
|
315
|
+
}, [onSelect, resetSearchState]);
|
|
316
|
+
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
if (!open || query) return;
|
|
319
|
+
|
|
320
|
+
let cancelled = false;
|
|
321
|
+
|
|
322
|
+
void getStarterResults()
|
|
323
|
+
.then(starterResults => {
|
|
324
|
+
if (cancelled) return;
|
|
325
|
+
setResults(starterResults);
|
|
326
|
+
setError(null);
|
|
327
|
+
})
|
|
328
|
+
.catch(() => {
|
|
329
|
+
if (cancelled) return;
|
|
330
|
+
setResults(COMMON_CRS.slice(0, MAX_STARTER_RESULTS));
|
|
331
|
+
setError(null);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
return () => {
|
|
335
|
+
cancelled = true;
|
|
336
|
+
};
|
|
337
|
+
}, [open, query]);
|
|
338
|
+
|
|
339
|
+
useEffect(() => {
|
|
340
|
+
return () => {
|
|
341
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
342
|
+
if (abortRef.current) abortRef.current.abort();
|
|
343
|
+
};
|
|
344
|
+
}, []);
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<Dialog
|
|
348
|
+
open={open}
|
|
349
|
+
onOpenChange={(nextOpen) => {
|
|
350
|
+
setOpen(nextOpen);
|
|
351
|
+
if (!nextOpen) resetSearchState();
|
|
352
|
+
}}
|
|
353
|
+
>
|
|
354
|
+
<DialogTrigger asChild>
|
|
355
|
+
{children || (
|
|
356
|
+
<button
|
|
357
|
+
type="button"
|
|
358
|
+
className="flex items-center gap-1 text-[10px] font-mono text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 transition-colors px-1.5 py-0.5 border border-teal-300/50 dark:border-teal-700/50 hover:bg-teal-50 dark:hover:bg-teal-950/50"
|
|
359
|
+
>
|
|
360
|
+
<Search className="h-2.5 w-2.5" />
|
|
361
|
+
EPSG
|
|
362
|
+
</button>
|
|
363
|
+
)}
|
|
364
|
+
</DialogTrigger>
|
|
365
|
+
<DialogContent className="max-w-sm gap-0 p-0 overflow-hidden" hideCloseButton>
|
|
366
|
+
<DialogHeader className="px-4 pt-4 pb-3">
|
|
367
|
+
<DialogTitle className="flex items-center gap-2 text-sm">
|
|
368
|
+
<Globe className="h-4 w-4 text-teal-500" />
|
|
369
|
+
EPSG Lookup
|
|
370
|
+
</DialogTitle>
|
|
371
|
+
<DialogDescription className="text-[11px] text-muted-foreground">
|
|
372
|
+
Search by code, name, country, or datum
|
|
373
|
+
</DialogDescription>
|
|
374
|
+
</DialogHeader>
|
|
375
|
+
|
|
376
|
+
<div className="px-4 pb-3">
|
|
377
|
+
<Input
|
|
378
|
+
placeholder="e.g. 2056, UTM, Switzerland, Tokyo..."
|
|
379
|
+
value={query}
|
|
380
|
+
onChange={handleInputChange}
|
|
381
|
+
leftIcon={loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Search className="h-3.5 w-3.5" />}
|
|
382
|
+
className="h-8 text-xs"
|
|
383
|
+
autoFocus
|
|
384
|
+
/>
|
|
385
|
+
</div>
|
|
386
|
+
|
|
387
|
+
{error && (
|
|
388
|
+
<p className="text-[11px] text-muted-foreground px-4 pb-2">{error}</p>
|
|
389
|
+
)}
|
|
390
|
+
|
|
391
|
+
{results.length > 0 && (
|
|
392
|
+
<div className="border-t overflow-y-auto max-h-[280px]">
|
|
393
|
+
{results.map((result) => (
|
|
394
|
+
<button
|
|
395
|
+
key={result.code}
|
|
396
|
+
className="w-full text-left px-4 py-2 hover:bg-muted/50 transition-colors border-b border-border/50 last:border-b-0"
|
|
397
|
+
onClick={() => handleSelect(result)}
|
|
398
|
+
>
|
|
399
|
+
<div className="flex items-baseline gap-2 min-w-0">
|
|
400
|
+
<code className="text-[11px] font-bold text-teal-600 dark:text-teal-400 shrink-0">{result.code}</code>
|
|
401
|
+
<span className="text-[11px] text-foreground truncate">{result.name}</span>
|
|
402
|
+
{result.kind && (
|
|
403
|
+
<span className="text-[9px] text-muted-foreground shrink-0 ml-auto">{result.kind}</span>
|
|
404
|
+
)}
|
|
405
|
+
</div>
|
|
406
|
+
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-muted-foreground">
|
|
407
|
+
{result.area && <span className="truncate">{result.area}</span>}
|
|
408
|
+
{result.datum && <span className="shrink-0">{result.datum}</span>}
|
|
409
|
+
{result.unit && <span className="shrink-0">{result.unit}</span>}
|
|
410
|
+
</div>
|
|
411
|
+
</button>
|
|
412
|
+
))}
|
|
413
|
+
</div>
|
|
414
|
+
)}
|
|
415
|
+
</DialogContent>
|
|
416
|
+
</Dialog>
|
|
417
|
+
);
|
|
418
|
+
}
|