@maplibre-yaml/core 0.1.0-alpha.0
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/LICENSE.md +21 -0
- package/dist/components/index.d.ts +106 -0
- package/dist/components/index.js +4332 -0
- package/dist/components/index.js.map +1 -0
- package/dist/index.d.ts +2717 -0
- package/dist/index.js +4305 -0
- package/dist/index.js.map +1 -0
- package/dist/map-renderer-RQc5_bdo.d.ts +149 -0
- package/dist/map.schema-EnZRrtIh.d.ts +68122 -0
- package/dist/schemas/index.d.ts +3151 -0
- package/dist/schemas/index.js +710 -0
- package/dist/schemas/index.js.map +1 -0
- package/package.json +79 -0
|
@@ -0,0 +1,4332 @@
|
|
|
1
|
+
import maplibregl2 from 'maplibre-gl';
|
|
2
|
+
import { parse } from 'yaml';
|
|
3
|
+
import { z, ZodError } from 'zod';
|
|
4
|
+
|
|
5
|
+
// @maplibre-yaml/core - Declarative web maps with YAML
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
// src/data/memory-cache.ts
|
|
9
|
+
var MemoryCache = class _MemoryCache {
|
|
10
|
+
static DEFAULT_CONFIG = {
|
|
11
|
+
maxSize: 100,
|
|
12
|
+
defaultTTL: 3e5,
|
|
13
|
+
// 5 minutes
|
|
14
|
+
useConditionalRequests: true
|
|
15
|
+
};
|
|
16
|
+
config;
|
|
17
|
+
cache = /* @__PURE__ */ new Map();
|
|
18
|
+
accessOrder = [];
|
|
19
|
+
stats = { hits: 0, misses: 0 };
|
|
20
|
+
/**
|
|
21
|
+
* Create a new MemoryCache instance
|
|
22
|
+
*
|
|
23
|
+
* @param config - Cache configuration options
|
|
24
|
+
*/
|
|
25
|
+
constructor(config) {
|
|
26
|
+
this.config = { ..._MemoryCache.DEFAULT_CONFIG, ...config };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Retrieve a cache entry
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* - Returns null if key doesn't exist
|
|
33
|
+
* - Returns null if entry has expired (and removes it)
|
|
34
|
+
* - Updates access order for LRU
|
|
35
|
+
* - Updates cache statistics
|
|
36
|
+
*
|
|
37
|
+
* @param key - Cache key (typically a URL)
|
|
38
|
+
* @returns Cache entry or null if not found/expired
|
|
39
|
+
*/
|
|
40
|
+
get(key) {
|
|
41
|
+
const entry = this.cache.get(key);
|
|
42
|
+
if (!entry) {
|
|
43
|
+
this.stats.misses++;
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const ttl = entry.ttl ?? this.config.defaultTTL;
|
|
47
|
+
const age = Date.now() - entry.timestamp;
|
|
48
|
+
if (age > ttl) {
|
|
49
|
+
this.cache.delete(key);
|
|
50
|
+
this.removeFromAccessOrder(key);
|
|
51
|
+
this.stats.misses++;
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
this.updateAccessOrder(key);
|
|
55
|
+
this.stats.hits++;
|
|
56
|
+
return entry;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Check if a key exists in cache (without checking expiration)
|
|
60
|
+
*
|
|
61
|
+
* @param key - Cache key
|
|
62
|
+
* @returns True if key exists in cache
|
|
63
|
+
*/
|
|
64
|
+
has(key) {
|
|
65
|
+
return this.cache.has(key);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Store a cache entry
|
|
69
|
+
*
|
|
70
|
+
* @remarks
|
|
71
|
+
* - Evicts least recently used entries if at capacity
|
|
72
|
+
* - Updates access order
|
|
73
|
+
*
|
|
74
|
+
* @param key - Cache key (typically a URL)
|
|
75
|
+
* @param entry - Cache entry to store
|
|
76
|
+
*/
|
|
77
|
+
set(key, entry) {
|
|
78
|
+
while (this.cache.size >= this.config.maxSize && !this.cache.has(key)) {
|
|
79
|
+
const oldest = this.accessOrder.shift();
|
|
80
|
+
if (oldest) {
|
|
81
|
+
this.cache.delete(oldest);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
this.cache.set(key, entry);
|
|
85
|
+
this.updateAccessOrder(key);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Delete a cache entry
|
|
89
|
+
*
|
|
90
|
+
* @param key - Cache key
|
|
91
|
+
* @returns True if entry was deleted, false if it didn't exist
|
|
92
|
+
*/
|
|
93
|
+
delete(key) {
|
|
94
|
+
const existed = this.cache.delete(key);
|
|
95
|
+
if (existed) {
|
|
96
|
+
this.removeFromAccessOrder(key);
|
|
97
|
+
}
|
|
98
|
+
return existed;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Clear all cache entries and reset statistics
|
|
102
|
+
*/
|
|
103
|
+
clear() {
|
|
104
|
+
this.cache.clear();
|
|
105
|
+
this.accessOrder = [];
|
|
106
|
+
this.stats = { hits: 0, misses: 0 };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Remove expired entries from cache
|
|
110
|
+
*
|
|
111
|
+
* @remarks
|
|
112
|
+
* Iterates through all entries and removes those that have exceeded their TTL.
|
|
113
|
+
* This is useful for periodic cleanup.
|
|
114
|
+
*
|
|
115
|
+
* @returns Number of entries removed
|
|
116
|
+
*/
|
|
117
|
+
prune() {
|
|
118
|
+
let removed = 0;
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
for (const [key, entry] of this.cache.entries()) {
|
|
121
|
+
const ttl = entry.ttl ?? this.config.defaultTTL;
|
|
122
|
+
const age = now - entry.timestamp;
|
|
123
|
+
if (age > ttl) {
|
|
124
|
+
this.cache.delete(key);
|
|
125
|
+
this.removeFromAccessOrder(key);
|
|
126
|
+
removed++;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return removed;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Get cache statistics
|
|
133
|
+
*
|
|
134
|
+
* @returns Current cache statistics including hit rate
|
|
135
|
+
*/
|
|
136
|
+
getStats() {
|
|
137
|
+
const total = this.stats.hits + this.stats.misses;
|
|
138
|
+
const hitRate = total > 0 ? this.stats.hits / total * 100 : 0;
|
|
139
|
+
return {
|
|
140
|
+
size: this.cache.size,
|
|
141
|
+
hits: this.stats.hits,
|
|
142
|
+
misses: this.stats.misses,
|
|
143
|
+
hitRate: Math.round(hitRate * 100) / 100
|
|
144
|
+
// Round to 2 decimal places
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Get conditional request headers for HTTP caching
|
|
149
|
+
*
|
|
150
|
+
* @remarks
|
|
151
|
+
* Returns appropriate If-None-Match and/or If-Modified-Since headers
|
|
152
|
+
* based on cached entry metadata. Returns empty object if:
|
|
153
|
+
* - Key doesn't exist in cache
|
|
154
|
+
* - Entry has expired
|
|
155
|
+
* - useConditionalRequests is false
|
|
156
|
+
*
|
|
157
|
+
* @param key - Cache key
|
|
158
|
+
* @returns Object with conditional headers (may be empty)
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* ```typescript
|
|
162
|
+
* const headers = cache.getConditionalHeaders(url);
|
|
163
|
+
* const response = await fetch(url, { headers });
|
|
164
|
+
* if (response.status === 304) {
|
|
165
|
+
* // Use cached data
|
|
166
|
+
* }
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
getConditionalHeaders(key) {
|
|
170
|
+
if (!this.config.useConditionalRequests) {
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
const entry = this.get(key);
|
|
174
|
+
if (!entry) {
|
|
175
|
+
return {};
|
|
176
|
+
}
|
|
177
|
+
const headers = {};
|
|
178
|
+
if (entry.etag) {
|
|
179
|
+
headers["If-None-Match"] = entry.etag;
|
|
180
|
+
}
|
|
181
|
+
if (entry.lastModified) {
|
|
182
|
+
headers["If-Modified-Since"] = entry.lastModified;
|
|
183
|
+
}
|
|
184
|
+
return headers;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Update the last access time for an entry
|
|
188
|
+
*
|
|
189
|
+
* @remarks
|
|
190
|
+
* Useful for keeping an entry "fresh" without modifying its data.
|
|
191
|
+
* Updates the access order for LRU.
|
|
192
|
+
*
|
|
193
|
+
* @param key - Cache key
|
|
194
|
+
*/
|
|
195
|
+
touch(key) {
|
|
196
|
+
if (this.cache.has(key)) {
|
|
197
|
+
this.updateAccessOrder(key);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Update access order for LRU tracking
|
|
202
|
+
*
|
|
203
|
+
* @param key - Cache key
|
|
204
|
+
*/
|
|
205
|
+
updateAccessOrder(key) {
|
|
206
|
+
this.removeFromAccessOrder(key);
|
|
207
|
+
this.accessOrder.push(key);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Remove a key from access order array
|
|
211
|
+
*
|
|
212
|
+
* @param key - Cache key
|
|
213
|
+
*/
|
|
214
|
+
removeFromAccessOrder(key) {
|
|
215
|
+
const index = this.accessOrder.indexOf(key);
|
|
216
|
+
if (index !== -1) {
|
|
217
|
+
this.accessOrder.splice(index, 1);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// src/data/retry-manager.ts
|
|
223
|
+
var MaxRetriesExceededError = class _MaxRetriesExceededError extends Error {
|
|
224
|
+
/**
|
|
225
|
+
* Create a MaxRetriesExceededError
|
|
226
|
+
*
|
|
227
|
+
* @param lastError - The error from the final attempt
|
|
228
|
+
* @param attempts - Number of attempts made
|
|
229
|
+
*/
|
|
230
|
+
constructor(lastError, attempts) {
|
|
231
|
+
super(
|
|
232
|
+
`Maximum retry attempts (${attempts}) exceeded. Last error: ${lastError.message}`
|
|
233
|
+
);
|
|
234
|
+
this.lastError = lastError;
|
|
235
|
+
this.attempts = attempts;
|
|
236
|
+
this.name = "MaxRetriesExceededError";
|
|
237
|
+
Object.setPrototypeOf(this, _MaxRetriesExceededError.prototype);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
var RetryManager = class _RetryManager {
|
|
241
|
+
static DEFAULT_CONFIG = {
|
|
242
|
+
maxRetries: 10,
|
|
243
|
+
initialDelay: 1e3,
|
|
244
|
+
maxDelay: 3e4,
|
|
245
|
+
backoffFactor: 2,
|
|
246
|
+
jitter: true,
|
|
247
|
+
jitterFactor: 0.25
|
|
248
|
+
};
|
|
249
|
+
config;
|
|
250
|
+
/**
|
|
251
|
+
* Create a new RetryManager instance
|
|
252
|
+
*
|
|
253
|
+
* @param config - Retry configuration options
|
|
254
|
+
*/
|
|
255
|
+
constructor(config) {
|
|
256
|
+
this.config = { ..._RetryManager.DEFAULT_CONFIG, ...config };
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Execute a function with retry logic
|
|
260
|
+
*
|
|
261
|
+
* @typeParam T - Return type of the function
|
|
262
|
+
* @param fn - Async function to execute with retries
|
|
263
|
+
* @param callbacks - Optional lifecycle callbacks
|
|
264
|
+
* @returns Promise that resolves with the function's result
|
|
265
|
+
* @throws {MaxRetriesExceededError} When all retry attempts fail
|
|
266
|
+
*
|
|
267
|
+
* @example
|
|
268
|
+
* ```typescript
|
|
269
|
+
* const data = await retry.execute(
|
|
270
|
+
* () => fetchData(url),
|
|
271
|
+
* {
|
|
272
|
+
* isRetryable: (error) => {
|
|
273
|
+
* // Don't retry 4xx errors except 429 (rate limit)
|
|
274
|
+
* if (error.message.includes('429')) return true;
|
|
275
|
+
* if (error.message.match(/4\d\d/)) return false;
|
|
276
|
+
* return true;
|
|
277
|
+
* },
|
|
278
|
+
* }
|
|
279
|
+
* );
|
|
280
|
+
* ```
|
|
281
|
+
*/
|
|
282
|
+
async execute(fn, callbacks) {
|
|
283
|
+
let lastError = null;
|
|
284
|
+
let attempt = 0;
|
|
285
|
+
while (attempt <= this.config.maxRetries) {
|
|
286
|
+
attempt++;
|
|
287
|
+
try {
|
|
288
|
+
const result = await fn();
|
|
289
|
+
callbacks?.onSuccess?.(attempt);
|
|
290
|
+
return result;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
293
|
+
if (callbacks?.isRetryable && !callbacks.isRetryable(lastError)) {
|
|
294
|
+
throw lastError;
|
|
295
|
+
}
|
|
296
|
+
if (attempt > this.config.maxRetries) {
|
|
297
|
+
callbacks?.onExhausted?.(attempt, lastError);
|
|
298
|
+
throw new MaxRetriesExceededError(lastError, attempt);
|
|
299
|
+
}
|
|
300
|
+
const delay = this.calculateDelay(attempt);
|
|
301
|
+
callbacks?.onRetry?.(attempt, delay, lastError);
|
|
302
|
+
await this.sleep(delay);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
throw new MaxRetriesExceededError(
|
|
306
|
+
lastError || new Error("Unknown error"),
|
|
307
|
+
attempt
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Calculate delay for a given attempt using exponential backoff
|
|
312
|
+
*
|
|
313
|
+
* @param attempt - Current attempt number (1-indexed)
|
|
314
|
+
* @returns Delay in milliseconds
|
|
315
|
+
*
|
|
316
|
+
* @example
|
|
317
|
+
* ```typescript
|
|
318
|
+
* const retry = new RetryManager({ initialDelay: 1000, backoffFactor: 2 });
|
|
319
|
+
* console.log(retry.calculateDelay(1)); // ~1000ms
|
|
320
|
+
* console.log(retry.calculateDelay(2)); // ~2000ms
|
|
321
|
+
* console.log(retry.calculateDelay(3)); // ~4000ms
|
|
322
|
+
* ```
|
|
323
|
+
*/
|
|
324
|
+
calculateDelay(attempt) {
|
|
325
|
+
let delay = this.config.initialDelay * Math.pow(this.config.backoffFactor, attempt - 1);
|
|
326
|
+
delay = Math.min(delay, this.config.maxDelay);
|
|
327
|
+
if (this.config.jitter) {
|
|
328
|
+
const jitterRange = delay * this.config.jitterFactor;
|
|
329
|
+
const jitterOffset = (Math.random() * 2 - 1) * jitterRange;
|
|
330
|
+
delay = delay + jitterOffset;
|
|
331
|
+
}
|
|
332
|
+
return Math.max(0, Math.round(delay));
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Reset internal state
|
|
336
|
+
*
|
|
337
|
+
* @remarks
|
|
338
|
+
* Currently this class is stateless, but this method is provided
|
|
339
|
+
* for API consistency and future extensibility.
|
|
340
|
+
*/
|
|
341
|
+
reset() {
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Sleep for specified milliseconds
|
|
345
|
+
*
|
|
346
|
+
* @param ms - Milliseconds to sleep
|
|
347
|
+
* @returns Promise that resolves after the delay
|
|
348
|
+
*/
|
|
349
|
+
sleep(ms) {
|
|
350
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// src/data/data-fetcher.ts
|
|
355
|
+
var DataFetcher = class _DataFetcher {
|
|
356
|
+
static DEFAULT_CONFIG = {
|
|
357
|
+
cache: {
|
|
358
|
+
enabled: true,
|
|
359
|
+
defaultTTL: 3e5,
|
|
360
|
+
// 5 minutes
|
|
361
|
+
maxSize: 100
|
|
362
|
+
},
|
|
363
|
+
retry: {
|
|
364
|
+
enabled: true,
|
|
365
|
+
maxRetries: 3,
|
|
366
|
+
initialDelay: 1e3,
|
|
367
|
+
maxDelay: 1e4
|
|
368
|
+
},
|
|
369
|
+
timeout: 3e4,
|
|
370
|
+
defaultHeaders: {
|
|
371
|
+
Accept: "application/geo+json,application/json"
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
config;
|
|
375
|
+
cache;
|
|
376
|
+
retryManager;
|
|
377
|
+
activeRequests = /* @__PURE__ */ new Map();
|
|
378
|
+
/**
|
|
379
|
+
* Create a new DataFetcher instance
|
|
380
|
+
*
|
|
381
|
+
* @param config - Fetcher configuration
|
|
382
|
+
*/
|
|
383
|
+
constructor(config) {
|
|
384
|
+
this.config = this.mergeConfig(config);
|
|
385
|
+
this.cache = new MemoryCache({
|
|
386
|
+
maxSize: this.config.cache.maxSize,
|
|
387
|
+
defaultTTL: this.config.cache.defaultTTL,
|
|
388
|
+
useConditionalRequests: true
|
|
389
|
+
});
|
|
390
|
+
this.retryManager = new RetryManager({
|
|
391
|
+
maxRetries: this.config.retry.maxRetries,
|
|
392
|
+
initialDelay: this.config.retry.initialDelay,
|
|
393
|
+
maxDelay: this.config.retry.maxDelay
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Fetch GeoJSON data from a URL
|
|
398
|
+
*
|
|
399
|
+
* @param url - URL to fetch from
|
|
400
|
+
* @param options - Fetch options
|
|
401
|
+
* @returns Fetch result with data and metadata
|
|
402
|
+
* @throws {Error} On network error, timeout, invalid JSON, or non-GeoJSON response
|
|
403
|
+
*
|
|
404
|
+
* @example
|
|
405
|
+
* ```typescript
|
|
406
|
+
* const result = await fetcher.fetch(
|
|
407
|
+
* 'https://example.com/data.geojson',
|
|
408
|
+
* {
|
|
409
|
+
* ttl: 60000, // 1 minute cache
|
|
410
|
+
* onRetry: (attempt, delay, error) => {
|
|
411
|
+
* console.log(`Retry ${attempt} in ${delay}ms: ${error.message}`);
|
|
412
|
+
* },
|
|
413
|
+
* }
|
|
414
|
+
* );
|
|
415
|
+
* ```
|
|
416
|
+
*/
|
|
417
|
+
async fetch(url, options = {}) {
|
|
418
|
+
const startTime = Date.now();
|
|
419
|
+
options.onStart?.();
|
|
420
|
+
try {
|
|
421
|
+
if (this.config.cache.enabled && !options.skipCache) {
|
|
422
|
+
const cached = this.cache.get(url);
|
|
423
|
+
if (cached) {
|
|
424
|
+
const result2 = {
|
|
425
|
+
data: cached.data,
|
|
426
|
+
fromCache: true,
|
|
427
|
+
featureCount: cached.data.features.length,
|
|
428
|
+
duration: Date.now() - startTime
|
|
429
|
+
};
|
|
430
|
+
options.onComplete?.(cached.data, true);
|
|
431
|
+
return result2;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const data = await this.fetchWithRetry(url, options);
|
|
435
|
+
if (this.config.cache.enabled && !options.skipCache) {
|
|
436
|
+
const cacheEntry = {
|
|
437
|
+
data,
|
|
438
|
+
timestamp: Date.now(),
|
|
439
|
+
ttl: options.ttl
|
|
440
|
+
};
|
|
441
|
+
this.cache.set(url, cacheEntry);
|
|
442
|
+
}
|
|
443
|
+
const result = {
|
|
444
|
+
data,
|
|
445
|
+
fromCache: false,
|
|
446
|
+
featureCount: data.features.length,
|
|
447
|
+
duration: Date.now() - startTime
|
|
448
|
+
};
|
|
449
|
+
options.onComplete?.(data, false);
|
|
450
|
+
return result;
|
|
451
|
+
} catch (error) {
|
|
452
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
453
|
+
options.onError?.(err);
|
|
454
|
+
throw err;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Prefetch data and store in cache
|
|
459
|
+
*
|
|
460
|
+
* @remarks
|
|
461
|
+
* Useful for preloading data that will be needed soon.
|
|
462
|
+
* Does not return the data.
|
|
463
|
+
*
|
|
464
|
+
* @param url - URL to prefetch
|
|
465
|
+
* @param ttl - Optional custom TTL for cached entry
|
|
466
|
+
*
|
|
467
|
+
* @example
|
|
468
|
+
* ```typescript
|
|
469
|
+
* // Prefetch data for quick access later
|
|
470
|
+
* await fetcher.prefetch('https://example.com/data.geojson', 600000);
|
|
471
|
+
* ```
|
|
472
|
+
*/
|
|
473
|
+
async prefetch(url, ttl) {
|
|
474
|
+
await this.fetch(url, { ttl, skipCache: false });
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Invalidate cached entry for a URL
|
|
478
|
+
*
|
|
479
|
+
* @param url - URL to invalidate
|
|
480
|
+
*
|
|
481
|
+
* @example
|
|
482
|
+
* ```typescript
|
|
483
|
+
* // Force next fetch to get fresh data
|
|
484
|
+
* fetcher.invalidate('https://example.com/data.geojson');
|
|
485
|
+
* ```
|
|
486
|
+
*/
|
|
487
|
+
invalidate(url) {
|
|
488
|
+
this.cache.delete(url);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Clear all cached entries
|
|
492
|
+
*/
|
|
493
|
+
clearCache() {
|
|
494
|
+
this.cache.clear();
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Get cache statistics
|
|
498
|
+
*
|
|
499
|
+
* @returns Cache stats including size, hits, misses, and hit rate
|
|
500
|
+
*/
|
|
501
|
+
getCacheStats() {
|
|
502
|
+
return this.cache.getStats();
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Abort all active requests
|
|
506
|
+
*/
|
|
507
|
+
abortAll() {
|
|
508
|
+
for (const controller of this.activeRequests.values()) {
|
|
509
|
+
controller.abort();
|
|
510
|
+
}
|
|
511
|
+
this.activeRequests.clear();
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Fetch with retry logic
|
|
515
|
+
*/
|
|
516
|
+
async fetchWithRetry(url, options) {
|
|
517
|
+
if (!this.config.retry.enabled) {
|
|
518
|
+
return this.performFetch(url, options);
|
|
519
|
+
}
|
|
520
|
+
return this.retryManager.execute(
|
|
521
|
+
() => this.performFetch(url, options),
|
|
522
|
+
{
|
|
523
|
+
onRetry: options.onRetry,
|
|
524
|
+
isRetryable: (error) => this.isRetryableError(error)
|
|
525
|
+
}
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Perform the actual HTTP fetch
|
|
530
|
+
*/
|
|
531
|
+
async performFetch(url, options) {
|
|
532
|
+
const controller = options.signal ? new AbortController() : new AbortController();
|
|
533
|
+
if (options.signal) {
|
|
534
|
+
options.signal.addEventListener("abort", () => controller.abort());
|
|
535
|
+
}
|
|
536
|
+
const timeoutId = setTimeout(() => {
|
|
537
|
+
controller.abort();
|
|
538
|
+
}, this.config.timeout);
|
|
539
|
+
this.activeRequests.set(url, controller);
|
|
540
|
+
try {
|
|
541
|
+
const headers = {
|
|
542
|
+
...this.config.defaultHeaders,
|
|
543
|
+
...options.headers
|
|
544
|
+
};
|
|
545
|
+
if (this.config.cache.enabled && this.cache.has(url)) {
|
|
546
|
+
const conditionalHeaders = this.cache.getConditionalHeaders(url);
|
|
547
|
+
Object.assign(headers, conditionalHeaders);
|
|
548
|
+
}
|
|
549
|
+
const response = await fetch(url, {
|
|
550
|
+
signal: controller.signal,
|
|
551
|
+
headers
|
|
552
|
+
});
|
|
553
|
+
if (response.status === 304) {
|
|
554
|
+
const cached = this.cache.get(url);
|
|
555
|
+
if (cached) {
|
|
556
|
+
this.cache.touch(url);
|
|
557
|
+
return cached.data;
|
|
558
|
+
}
|
|
559
|
+
throw new Error("304 Not Modified but no cached data available");
|
|
560
|
+
}
|
|
561
|
+
if (!response.ok) {
|
|
562
|
+
throw new Error(
|
|
563
|
+
`HTTP ${response.status}: ${response.statusText} for ${url}`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
let data;
|
|
567
|
+
try {
|
|
568
|
+
data = await response.json();
|
|
569
|
+
} catch (error) {
|
|
570
|
+
throw new Error(`Invalid JSON response from ${url}`);
|
|
571
|
+
}
|
|
572
|
+
if (!this.isValidGeoJSON(data)) {
|
|
573
|
+
throw new Error(`Response from ${url} is not valid GeoJSON`);
|
|
574
|
+
}
|
|
575
|
+
if (this.config.cache.enabled) {
|
|
576
|
+
const etag = response.headers.get("etag");
|
|
577
|
+
const lastModified = response.headers.get("last-modified");
|
|
578
|
+
if (etag || lastModified) {
|
|
579
|
+
const cached = this.cache.get(url);
|
|
580
|
+
if (cached) {
|
|
581
|
+
this.cache.set(url, {
|
|
582
|
+
...cached,
|
|
583
|
+
etag: etag || cached.etag,
|
|
584
|
+
lastModified: lastModified || cached.lastModified
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return data;
|
|
590
|
+
} finally {
|
|
591
|
+
clearTimeout(timeoutId);
|
|
592
|
+
this.activeRequests.delete(url);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Check if an error should trigger a retry
|
|
597
|
+
*/
|
|
598
|
+
isRetryableError(error) {
|
|
599
|
+
const message = error.message.toLowerCase();
|
|
600
|
+
if (message.includes("http 4") && !message.includes("429")) {
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
if (message.includes("invalid json") || message.includes("not valid geojson")) {
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Validate that data is a GeoJSON FeatureCollection
|
|
610
|
+
*/
|
|
611
|
+
isValidGeoJSON(data) {
|
|
612
|
+
if (typeof data !== "object" || data === null) {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
const obj = data;
|
|
616
|
+
return obj.type === "FeatureCollection" && Array.isArray(obj.features);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Merge partial config with defaults
|
|
620
|
+
*/
|
|
621
|
+
mergeConfig(partial) {
|
|
622
|
+
if (!partial) return _DataFetcher.DEFAULT_CONFIG;
|
|
623
|
+
return {
|
|
624
|
+
cache: { ..._DataFetcher.DEFAULT_CONFIG.cache, ...partial.cache },
|
|
625
|
+
retry: { ..._DataFetcher.DEFAULT_CONFIG.retry, ...partial.retry },
|
|
626
|
+
timeout: partial.timeout ?? _DataFetcher.DEFAULT_CONFIG.timeout,
|
|
627
|
+
defaultHeaders: {
|
|
628
|
+
..._DataFetcher.DEFAULT_CONFIG.defaultHeaders,
|
|
629
|
+
...partial.defaultHeaders
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
// src/data/polling-manager.ts
|
|
636
|
+
var PollingManager = class {
|
|
637
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
638
|
+
visibilityListener = null;
|
|
639
|
+
constructor() {
|
|
640
|
+
this.setupVisibilityListener();
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Start a new polling subscription.
|
|
644
|
+
*
|
|
645
|
+
* @param id - Unique identifier for the subscription
|
|
646
|
+
* @param config - Polling configuration
|
|
647
|
+
* @throws Error if a subscription with the same ID already exists
|
|
648
|
+
*
|
|
649
|
+
* @example
|
|
650
|
+
* ```typescript
|
|
651
|
+
* polling.start('layer-1', {
|
|
652
|
+
* interval: 5000,
|
|
653
|
+
* onTick: async () => {
|
|
654
|
+
* await updateLayerData();
|
|
655
|
+
* },
|
|
656
|
+
* });
|
|
657
|
+
* ```
|
|
658
|
+
*/
|
|
659
|
+
start(id, config) {
|
|
660
|
+
if (this.subscriptions.has(id)) {
|
|
661
|
+
throw new Error(`Polling subscription with id "${id}" already exists`);
|
|
662
|
+
}
|
|
663
|
+
const subscription = {
|
|
664
|
+
config,
|
|
665
|
+
state: {
|
|
666
|
+
isActive: true,
|
|
667
|
+
isPaused: false,
|
|
668
|
+
lastTick: null,
|
|
669
|
+
nextTick: null,
|
|
670
|
+
tickCount: 0,
|
|
671
|
+
errorCount: 0
|
|
672
|
+
},
|
|
673
|
+
timerId: null,
|
|
674
|
+
isExecuting: false,
|
|
675
|
+
pausedByVisibility: false
|
|
676
|
+
};
|
|
677
|
+
this.subscriptions.set(id, subscription);
|
|
678
|
+
if (config.immediate) {
|
|
679
|
+
this.executeTick(id);
|
|
680
|
+
} else {
|
|
681
|
+
this.scheduleNextTick(id);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Stop a polling subscription and clean up resources.
|
|
686
|
+
*
|
|
687
|
+
* @param id - Subscription identifier
|
|
688
|
+
*
|
|
689
|
+
* @example
|
|
690
|
+
* ```typescript
|
|
691
|
+
* polling.stop('layer-1');
|
|
692
|
+
* ```
|
|
693
|
+
*/
|
|
694
|
+
stop(id) {
|
|
695
|
+
const subscription = this.subscriptions.get(id);
|
|
696
|
+
if (!subscription) return;
|
|
697
|
+
if (subscription.timerId !== null) {
|
|
698
|
+
clearTimeout(subscription.timerId);
|
|
699
|
+
}
|
|
700
|
+
subscription.state.isActive = false;
|
|
701
|
+
this.subscriptions.delete(id);
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Stop all polling subscriptions.
|
|
705
|
+
*
|
|
706
|
+
* @example
|
|
707
|
+
* ```typescript
|
|
708
|
+
* polling.stopAll();
|
|
709
|
+
* ```
|
|
710
|
+
*/
|
|
711
|
+
stopAll() {
|
|
712
|
+
for (const id of this.subscriptions.keys()) {
|
|
713
|
+
this.stop(id);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Pause a polling subscription without stopping it.
|
|
718
|
+
*
|
|
719
|
+
* @param id - Subscription identifier
|
|
720
|
+
*
|
|
721
|
+
* @remarks
|
|
722
|
+
* Paused subscriptions can be resumed with {@link resume}.
|
|
723
|
+
* The subscription maintains its state while paused.
|
|
724
|
+
*
|
|
725
|
+
* @example
|
|
726
|
+
* ```typescript
|
|
727
|
+
* polling.pause('layer-1');
|
|
728
|
+
* ```
|
|
729
|
+
*/
|
|
730
|
+
pause(id) {
|
|
731
|
+
const subscription = this.subscriptions.get(id);
|
|
732
|
+
if (!subscription || subscription.state.isPaused) return;
|
|
733
|
+
if (subscription.timerId !== null) {
|
|
734
|
+
clearTimeout(subscription.timerId);
|
|
735
|
+
subscription.timerId = null;
|
|
736
|
+
}
|
|
737
|
+
subscription.state.isPaused = true;
|
|
738
|
+
subscription.state.nextTick = null;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Pause all active polling subscriptions.
|
|
742
|
+
*
|
|
743
|
+
* @example
|
|
744
|
+
* ```typescript
|
|
745
|
+
* polling.pauseAll();
|
|
746
|
+
* ```
|
|
747
|
+
*/
|
|
748
|
+
pauseAll() {
|
|
749
|
+
for (const id of this.subscriptions.keys()) {
|
|
750
|
+
this.pause(id);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Resume a paused polling subscription.
|
|
755
|
+
*
|
|
756
|
+
* @param id - Subscription identifier
|
|
757
|
+
*
|
|
758
|
+
* @example
|
|
759
|
+
* ```typescript
|
|
760
|
+
* polling.resume('layer-1');
|
|
761
|
+
* ```
|
|
762
|
+
*/
|
|
763
|
+
resume(id) {
|
|
764
|
+
const subscription = this.subscriptions.get(id);
|
|
765
|
+
if (!subscription || !subscription.state.isPaused) return;
|
|
766
|
+
subscription.state.isPaused = false;
|
|
767
|
+
subscription.pausedByVisibility = false;
|
|
768
|
+
this.scheduleNextTick(id);
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Resume all paused polling subscriptions.
|
|
772
|
+
*
|
|
773
|
+
* @example
|
|
774
|
+
* ```typescript
|
|
775
|
+
* polling.resumeAll();
|
|
776
|
+
* ```
|
|
777
|
+
*/
|
|
778
|
+
resumeAll() {
|
|
779
|
+
for (const id of this.subscriptions.keys()) {
|
|
780
|
+
this.resume(id);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Trigger an immediate execution of the polling tick.
|
|
785
|
+
*
|
|
786
|
+
* @param id - Subscription identifier
|
|
787
|
+
* @returns Promise that resolves when the tick completes
|
|
788
|
+
* @throws Error if the subscription doesn't exist
|
|
789
|
+
*
|
|
790
|
+
* @remarks
|
|
791
|
+
* This does not affect the regular polling schedule. The next scheduled
|
|
792
|
+
* tick will still occur at the expected time.
|
|
793
|
+
*
|
|
794
|
+
* @example
|
|
795
|
+
* ```typescript
|
|
796
|
+
* await polling.triggerNow('layer-1');
|
|
797
|
+
* ```
|
|
798
|
+
*/
|
|
799
|
+
async triggerNow(id) {
|
|
800
|
+
const subscription = this.subscriptions.get(id);
|
|
801
|
+
if (!subscription) {
|
|
802
|
+
throw new Error(`Polling subscription "${id}" not found`);
|
|
803
|
+
}
|
|
804
|
+
await this.executeTick(id);
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Get the current state of a polling subscription.
|
|
808
|
+
*
|
|
809
|
+
* @param id - Subscription identifier
|
|
810
|
+
* @returns Current state or null if not found
|
|
811
|
+
*
|
|
812
|
+
* @example
|
|
813
|
+
* ```typescript
|
|
814
|
+
* const state = polling.getState('layer-1');
|
|
815
|
+
* if (state) {
|
|
816
|
+
* console.log(`Ticks: ${state.tickCount}, Errors: ${state.errorCount}`);
|
|
817
|
+
* }
|
|
818
|
+
* ```
|
|
819
|
+
*/
|
|
820
|
+
getState(id) {
|
|
821
|
+
const subscription = this.subscriptions.get(id);
|
|
822
|
+
return subscription ? { ...subscription.state } : null;
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Get all active polling subscription IDs.
|
|
826
|
+
*
|
|
827
|
+
* @returns Array of subscription IDs
|
|
828
|
+
*
|
|
829
|
+
* @example
|
|
830
|
+
* ```typescript
|
|
831
|
+
* const ids = polling.getActiveIds();
|
|
832
|
+
* console.log(`Active pollers: ${ids.join(', ')}`);
|
|
833
|
+
* ```
|
|
834
|
+
*/
|
|
835
|
+
getActiveIds() {
|
|
836
|
+
return Array.from(this.subscriptions.keys());
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Check if a polling subscription exists.
|
|
840
|
+
*
|
|
841
|
+
* @param id - Subscription identifier
|
|
842
|
+
* @returns True if the subscription exists
|
|
843
|
+
*
|
|
844
|
+
* @example
|
|
845
|
+
* ```typescript
|
|
846
|
+
* if (polling.has('layer-1')) {
|
|
847
|
+
* polling.pause('layer-1');
|
|
848
|
+
* }
|
|
849
|
+
* ```
|
|
850
|
+
*/
|
|
851
|
+
has(id) {
|
|
852
|
+
return this.subscriptions.has(id);
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Update the interval for an active polling subscription.
|
|
856
|
+
*
|
|
857
|
+
* @param id - Subscription identifier
|
|
858
|
+
* @param interval - New interval in milliseconds (minimum 1000ms)
|
|
859
|
+
* @throws Error if the subscription doesn't exist
|
|
860
|
+
*
|
|
861
|
+
* @remarks
|
|
862
|
+
* The new interval takes effect after the current tick completes.
|
|
863
|
+
*
|
|
864
|
+
* @example
|
|
865
|
+
* ```typescript
|
|
866
|
+
* polling.setInterval('layer-1', 10000);
|
|
867
|
+
* ```
|
|
868
|
+
*/
|
|
869
|
+
setInterval(id, interval) {
|
|
870
|
+
const subscription = this.subscriptions.get(id);
|
|
871
|
+
if (!subscription) {
|
|
872
|
+
throw new Error(`Polling subscription "${id}" not found`);
|
|
873
|
+
}
|
|
874
|
+
if (interval < 1e3) {
|
|
875
|
+
throw new Error("Interval must be at least 1000ms");
|
|
876
|
+
}
|
|
877
|
+
subscription.config.interval = interval;
|
|
878
|
+
if (!subscription.state.isPaused && !subscription.isExecuting && subscription.timerId !== null) {
|
|
879
|
+
clearTimeout(subscription.timerId);
|
|
880
|
+
this.scheduleNextTick(id);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Clean up all resources and stop all polling.
|
|
885
|
+
*
|
|
886
|
+
* @remarks
|
|
887
|
+
* Should be called when the polling manager is no longer needed.
|
|
888
|
+
* After calling destroy, the polling manager should not be used.
|
|
889
|
+
*
|
|
890
|
+
* @example
|
|
891
|
+
* ```typescript
|
|
892
|
+
* polling.destroy();
|
|
893
|
+
* ```
|
|
894
|
+
*/
|
|
895
|
+
destroy() {
|
|
896
|
+
this.stopAll();
|
|
897
|
+
this.teardownVisibilityListener();
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Execute a single tick for a subscription.
|
|
901
|
+
*/
|
|
902
|
+
async executeTick(id) {
|
|
903
|
+
const subscription = this.subscriptions.get(id);
|
|
904
|
+
if (!subscription || subscription.isExecuting) return;
|
|
905
|
+
subscription.isExecuting = true;
|
|
906
|
+
try {
|
|
907
|
+
await subscription.config.onTick();
|
|
908
|
+
subscription.state.tickCount++;
|
|
909
|
+
subscription.state.lastTick = Date.now();
|
|
910
|
+
} catch (error) {
|
|
911
|
+
subscription.state.errorCount++;
|
|
912
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
913
|
+
subscription.config.onError?.(err);
|
|
914
|
+
} finally {
|
|
915
|
+
subscription.isExecuting = false;
|
|
916
|
+
if (this.subscriptions.has(id) && subscription.state.isActive && !subscription.state.isPaused) {
|
|
917
|
+
this.scheduleNextTick(id);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Schedule the next tick for a subscription.
|
|
923
|
+
*/
|
|
924
|
+
scheduleNextTick(id) {
|
|
925
|
+
const subscription = this.subscriptions.get(id);
|
|
926
|
+
if (!subscription) return;
|
|
927
|
+
const nextTime = Date.now() + subscription.config.interval;
|
|
928
|
+
subscription.state.nextTick = nextTime;
|
|
929
|
+
subscription.timerId = setTimeout(() => {
|
|
930
|
+
this.executeTick(id);
|
|
931
|
+
}, subscription.config.interval);
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Setup document visibility listener for automatic pause/resume.
|
|
935
|
+
*/
|
|
936
|
+
setupVisibilityListener() {
|
|
937
|
+
if (typeof document === "undefined") return;
|
|
938
|
+
this.visibilityListener = () => {
|
|
939
|
+
if (document.hidden) {
|
|
940
|
+
this.handleVisibilityChange(true);
|
|
941
|
+
} else {
|
|
942
|
+
this.handleVisibilityChange(false);
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
document.addEventListener("visibilitychange", this.visibilityListener);
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Handle document visibility changes.
|
|
949
|
+
*/
|
|
950
|
+
handleVisibilityChange(hidden) {
|
|
951
|
+
for (const [id, subscription] of this.subscriptions) {
|
|
952
|
+
const pauseEnabled = subscription.config.pauseWhenHidden !== false;
|
|
953
|
+
if (hidden && pauseEnabled && !subscription.state.isPaused) {
|
|
954
|
+
subscription.pausedByVisibility = true;
|
|
955
|
+
this.pause(id);
|
|
956
|
+
} else if (!hidden && pauseEnabled && subscription.pausedByVisibility) {
|
|
957
|
+
this.resume(id);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Remove document visibility listener.
|
|
963
|
+
*/
|
|
964
|
+
teardownVisibilityListener() {
|
|
965
|
+
if (this.visibilityListener && typeof document !== "undefined") {
|
|
966
|
+
document.removeEventListener("visibilitychange", this.visibilityListener);
|
|
967
|
+
this.visibilityListener = null;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
// src/utils/event-emitter.ts
|
|
973
|
+
var EventEmitter = class {
|
|
974
|
+
handlers = /* @__PURE__ */ new Map();
|
|
975
|
+
/**
|
|
976
|
+
* Register an event handler
|
|
977
|
+
*
|
|
978
|
+
* @param event - Event name to listen for
|
|
979
|
+
* @param handler - Callback function to invoke when event is emitted
|
|
980
|
+
* @returns Unsubscribe function that removes this specific handler
|
|
981
|
+
*
|
|
982
|
+
* @example
|
|
983
|
+
* ```typescript
|
|
984
|
+
* const unsubscribe = emitter.on('message', (data) => {
|
|
985
|
+
* console.log(data.text);
|
|
986
|
+
* });
|
|
987
|
+
*
|
|
988
|
+
* // Later, to unsubscribe:
|
|
989
|
+
* unsubscribe();
|
|
990
|
+
* ```
|
|
991
|
+
*/
|
|
992
|
+
on(event, handler) {
|
|
993
|
+
if (!this.handlers.has(event)) {
|
|
994
|
+
this.handlers.set(event, /* @__PURE__ */ new Set());
|
|
995
|
+
}
|
|
996
|
+
this.handlers.get(event).add(handler);
|
|
997
|
+
return () => this.off(event, handler);
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Register a one-time event handler
|
|
1001
|
+
*
|
|
1002
|
+
* @remarks
|
|
1003
|
+
* The handler will be automatically removed after being invoked once.
|
|
1004
|
+
*
|
|
1005
|
+
* @param event - Event name to listen for
|
|
1006
|
+
* @param handler - Callback function to invoke once
|
|
1007
|
+
*
|
|
1008
|
+
* @example
|
|
1009
|
+
* ```typescript
|
|
1010
|
+
* emitter.once('connect', () => {
|
|
1011
|
+
* console.log('Connected!');
|
|
1012
|
+
* });
|
|
1013
|
+
* ```
|
|
1014
|
+
*/
|
|
1015
|
+
once(event, handler) {
|
|
1016
|
+
const onceWrapper = (data) => {
|
|
1017
|
+
this.off(event, onceWrapper);
|
|
1018
|
+
handler(data);
|
|
1019
|
+
};
|
|
1020
|
+
this.on(event, onceWrapper);
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Remove an event handler
|
|
1024
|
+
*
|
|
1025
|
+
* @param event - Event name
|
|
1026
|
+
* @param handler - Handler function to remove
|
|
1027
|
+
*
|
|
1028
|
+
* @example
|
|
1029
|
+
* ```typescript
|
|
1030
|
+
* const handler = (data) => console.log(data);
|
|
1031
|
+
* emitter.on('message', handler);
|
|
1032
|
+
* emitter.off('message', handler);
|
|
1033
|
+
* ```
|
|
1034
|
+
*/
|
|
1035
|
+
off(event, handler) {
|
|
1036
|
+
const handlers = this.handlers.get(event);
|
|
1037
|
+
if (handlers) {
|
|
1038
|
+
handlers.delete(handler);
|
|
1039
|
+
if (handlers.size === 0) {
|
|
1040
|
+
this.handlers.delete(event);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Emit an event to all registered handlers
|
|
1046
|
+
*
|
|
1047
|
+
* @remarks
|
|
1048
|
+
* This method is protected to ensure only the extending class can emit events.
|
|
1049
|
+
* All handlers are invoked synchronously in the order they were registered.
|
|
1050
|
+
*
|
|
1051
|
+
* @param event - Event name to emit
|
|
1052
|
+
* @param data - Event payload data
|
|
1053
|
+
*
|
|
1054
|
+
* @example
|
|
1055
|
+
* ```typescript
|
|
1056
|
+
* class MyEmitter extends EventEmitter<MyEvents> {
|
|
1057
|
+
* doSomething() {
|
|
1058
|
+
* this.emit('something-happened', { value: 42 });
|
|
1059
|
+
* }
|
|
1060
|
+
* }
|
|
1061
|
+
* ```
|
|
1062
|
+
*/
|
|
1063
|
+
emit(event, data) {
|
|
1064
|
+
const handlers = this.handlers.get(event);
|
|
1065
|
+
if (handlers) {
|
|
1066
|
+
for (const handler of handlers) {
|
|
1067
|
+
handler(data);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Remove all handlers for an event, or all handlers for all events
|
|
1073
|
+
*
|
|
1074
|
+
* @param event - Optional event name. If omitted, removes all handlers for all events.
|
|
1075
|
+
*
|
|
1076
|
+
* @example
|
|
1077
|
+
* ```typescript
|
|
1078
|
+
* // Remove all handlers for 'message' event
|
|
1079
|
+
* emitter.removeAllListeners('message');
|
|
1080
|
+
*
|
|
1081
|
+
* // Remove all handlers for all events
|
|
1082
|
+
* emitter.removeAllListeners();
|
|
1083
|
+
* ```
|
|
1084
|
+
*/
|
|
1085
|
+
removeAllListeners(event) {
|
|
1086
|
+
if (event) {
|
|
1087
|
+
this.handlers.delete(event);
|
|
1088
|
+
} else {
|
|
1089
|
+
this.handlers.clear();
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Get the number of handlers registered for an event
|
|
1094
|
+
*
|
|
1095
|
+
* @param event - Event name
|
|
1096
|
+
* @returns Number of registered handlers
|
|
1097
|
+
*
|
|
1098
|
+
* @example
|
|
1099
|
+
* ```typescript
|
|
1100
|
+
* const count = emitter.listenerCount('message');
|
|
1101
|
+
* console.log(`${count} handlers registered`);
|
|
1102
|
+
* ```
|
|
1103
|
+
*/
|
|
1104
|
+
listenerCount(event) {
|
|
1105
|
+
const handlers = this.handlers.get(event);
|
|
1106
|
+
return handlers ? handlers.size : 0;
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Get all event names that have registered handlers
|
|
1110
|
+
*
|
|
1111
|
+
* @returns Array of event names
|
|
1112
|
+
*
|
|
1113
|
+
* @example
|
|
1114
|
+
* ```typescript
|
|
1115
|
+
* const events = emitter.eventNames();
|
|
1116
|
+
* console.log('Events with handlers:', events);
|
|
1117
|
+
* ```
|
|
1118
|
+
*/
|
|
1119
|
+
eventNames() {
|
|
1120
|
+
return Array.from(this.handlers.keys());
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Check if an event has any registered handlers
|
|
1124
|
+
*
|
|
1125
|
+
* @param event - Event name
|
|
1126
|
+
* @returns True if the event has at least one handler
|
|
1127
|
+
*
|
|
1128
|
+
* @example
|
|
1129
|
+
* ```typescript
|
|
1130
|
+
* if (emitter.hasListeners('message')) {
|
|
1131
|
+
* emitter.emit('message', { text: 'Hello' });
|
|
1132
|
+
* }
|
|
1133
|
+
* ```
|
|
1134
|
+
*/
|
|
1135
|
+
hasListeners(event) {
|
|
1136
|
+
return this.listenerCount(event) > 0;
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
// src/data/streaming/base-connection.ts
|
|
1141
|
+
var BaseConnection = class extends EventEmitter {
|
|
1142
|
+
state = "disconnected";
|
|
1143
|
+
config;
|
|
1144
|
+
retryManager;
|
|
1145
|
+
reconnectAttempts = 0;
|
|
1146
|
+
manualDisconnect = false;
|
|
1147
|
+
/**
|
|
1148
|
+
* Create a new base connection.
|
|
1149
|
+
*
|
|
1150
|
+
* @param config - Connection configuration
|
|
1151
|
+
*/
|
|
1152
|
+
constructor(config) {
|
|
1153
|
+
super();
|
|
1154
|
+
this.config = {
|
|
1155
|
+
reconnect: true,
|
|
1156
|
+
reconnectConfig: {},
|
|
1157
|
+
...config
|
|
1158
|
+
};
|
|
1159
|
+
this.retryManager = new RetryManager({
|
|
1160
|
+
maxRetries: 10,
|
|
1161
|
+
initialDelay: 1e3,
|
|
1162
|
+
maxDelay: 3e4,
|
|
1163
|
+
backoffFactor: 2,
|
|
1164
|
+
jitter: true,
|
|
1165
|
+
jitterFactor: 0.25,
|
|
1166
|
+
...this.config.reconnectConfig
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Get current connection state.
|
|
1171
|
+
*
|
|
1172
|
+
* @returns Current state
|
|
1173
|
+
*
|
|
1174
|
+
* @example
|
|
1175
|
+
* ```typescript
|
|
1176
|
+
* const state = connection.getState();
|
|
1177
|
+
* if (state === 'connected') {
|
|
1178
|
+
* // Connection is ready
|
|
1179
|
+
* }
|
|
1180
|
+
* ```
|
|
1181
|
+
*/
|
|
1182
|
+
getState() {
|
|
1183
|
+
return this.state;
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Check if connection is currently connected.
|
|
1187
|
+
*
|
|
1188
|
+
* @returns True if connected
|
|
1189
|
+
*
|
|
1190
|
+
* @example
|
|
1191
|
+
* ```typescript
|
|
1192
|
+
* if (connection.isConnected()) {
|
|
1193
|
+
* connection.send(data);
|
|
1194
|
+
* }
|
|
1195
|
+
* ```
|
|
1196
|
+
*/
|
|
1197
|
+
isConnected() {
|
|
1198
|
+
return this.state === "connected";
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Get the number of reconnection attempts.
|
|
1202
|
+
*
|
|
1203
|
+
* @returns Number of reconnect attempts
|
|
1204
|
+
*/
|
|
1205
|
+
getReconnectAttempts() {
|
|
1206
|
+
return this.reconnectAttempts;
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Update connection state and emit state change event.
|
|
1210
|
+
*
|
|
1211
|
+
* @param newState - New connection state
|
|
1212
|
+
*
|
|
1213
|
+
* @remarks
|
|
1214
|
+
* Automatically emits 'stateChange' event when state changes.
|
|
1215
|
+
* Subclasses should call this method instead of setting state directly.
|
|
1216
|
+
*
|
|
1217
|
+
* @example
|
|
1218
|
+
* ```typescript
|
|
1219
|
+
* protected async connect() {
|
|
1220
|
+
* this.setState('connecting');
|
|
1221
|
+
* await this.establishConnection();
|
|
1222
|
+
* this.setState('connected');
|
|
1223
|
+
* }
|
|
1224
|
+
* ```
|
|
1225
|
+
*/
|
|
1226
|
+
setState(newState) {
|
|
1227
|
+
if (this.state === newState) return;
|
|
1228
|
+
const oldState = this.state;
|
|
1229
|
+
this.state = newState;
|
|
1230
|
+
this.emit("stateChange", { from: oldState, to: newState });
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Handle disconnection and optionally attempt reconnection.
|
|
1234
|
+
*
|
|
1235
|
+
* @param reason - Reason for disconnection
|
|
1236
|
+
*
|
|
1237
|
+
* @remarks
|
|
1238
|
+
* This method should be called by subclasses when the connection is lost.
|
|
1239
|
+
* It will:
|
|
1240
|
+
* 1. Emit 'disconnect' event
|
|
1241
|
+
* 2. Attempt reconnection if enabled and not manually disconnected
|
|
1242
|
+
* 3. Emit 'reconnecting', 'reconnected', or 'failed' events as appropriate
|
|
1243
|
+
*
|
|
1244
|
+
* @example
|
|
1245
|
+
* ```typescript
|
|
1246
|
+
* ws.onclose = () => {
|
|
1247
|
+
* this.handleDisconnect('Connection closed');
|
|
1248
|
+
* };
|
|
1249
|
+
* ```
|
|
1250
|
+
*/
|
|
1251
|
+
async handleDisconnect(reason) {
|
|
1252
|
+
const wasConnected = this.state === "connected";
|
|
1253
|
+
this.setState("disconnected");
|
|
1254
|
+
this.emit("disconnect", { reason });
|
|
1255
|
+
if (!this.config.reconnect || this.manualDisconnect || !wasConnected) {
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
await this.attemptReconnection();
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Attempt to reconnect with exponential backoff.
|
|
1262
|
+
*/
|
|
1263
|
+
async attemptReconnection() {
|
|
1264
|
+
this.setState("reconnecting");
|
|
1265
|
+
this.reconnectAttempts = 0;
|
|
1266
|
+
try {
|
|
1267
|
+
await this.retryManager.execute(
|
|
1268
|
+
async () => {
|
|
1269
|
+
this.reconnectAttempts++;
|
|
1270
|
+
await this.connect();
|
|
1271
|
+
},
|
|
1272
|
+
{
|
|
1273
|
+
onRetry: (attempt, delay) => {
|
|
1274
|
+
this.emit("reconnecting", { attempt, delay });
|
|
1275
|
+
},
|
|
1276
|
+
onSuccess: (attempts) => {
|
|
1277
|
+
this.reconnectAttempts = 0;
|
|
1278
|
+
this.emit("reconnected", { attempts });
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
);
|
|
1282
|
+
} catch (err) {
|
|
1283
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1284
|
+
this.setState("failed");
|
|
1285
|
+
this.emit("failed", {
|
|
1286
|
+
attempts: this.reconnectAttempts,
|
|
1287
|
+
lastError: error
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Mark disconnection as manual to prevent reconnection.
|
|
1293
|
+
*
|
|
1294
|
+
* @remarks
|
|
1295
|
+
* Should be called by subclasses in their disconnect() implementation
|
|
1296
|
+
* before closing the connection.
|
|
1297
|
+
*
|
|
1298
|
+
* @example
|
|
1299
|
+
* ```typescript
|
|
1300
|
+
* disconnect(): void {
|
|
1301
|
+
* this.setManualDisconnect();
|
|
1302
|
+
* this.ws.close();
|
|
1303
|
+
* }
|
|
1304
|
+
* ```
|
|
1305
|
+
*/
|
|
1306
|
+
setManualDisconnect() {
|
|
1307
|
+
this.manualDisconnect = true;
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Reset manual disconnect flag.
|
|
1311
|
+
*
|
|
1312
|
+
* @remarks
|
|
1313
|
+
* Should be called when establishing a new connection to allow
|
|
1314
|
+
* automatic reconnection for subsequent disconnections.
|
|
1315
|
+
*/
|
|
1316
|
+
resetManualDisconnect() {
|
|
1317
|
+
this.manualDisconnect = false;
|
|
1318
|
+
}
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
// src/data/streaming/sse-connection.ts
|
|
1322
|
+
var SSEConnection = class extends BaseConnection {
|
|
1323
|
+
eventSource = null;
|
|
1324
|
+
lastEventId = null;
|
|
1325
|
+
sseConfig;
|
|
1326
|
+
/**
|
|
1327
|
+
* Create a new SSE connection.
|
|
1328
|
+
*
|
|
1329
|
+
* @param config - SSE configuration
|
|
1330
|
+
*
|
|
1331
|
+
* @example
|
|
1332
|
+
* ```typescript
|
|
1333
|
+
* const connection = new SSEConnection({
|
|
1334
|
+
* url: 'https://api.example.com/stream',
|
|
1335
|
+
* eventTypes: ['message', 'update'],
|
|
1336
|
+
* withCredentials: true,
|
|
1337
|
+
* });
|
|
1338
|
+
* ```
|
|
1339
|
+
*/
|
|
1340
|
+
constructor(config) {
|
|
1341
|
+
super(config);
|
|
1342
|
+
this.sseConfig = {
|
|
1343
|
+
...this.config,
|
|
1344
|
+
eventTypes: config.eventTypes ?? ["message"],
|
|
1345
|
+
withCredentials: config.withCredentials ?? false
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Establish SSE connection.
|
|
1350
|
+
*
|
|
1351
|
+
* @remarks
|
|
1352
|
+
* Creates an EventSource and sets up event listeners for:
|
|
1353
|
+
* - Connection open
|
|
1354
|
+
* - Message events (for each configured event type)
|
|
1355
|
+
* - Error events
|
|
1356
|
+
*
|
|
1357
|
+
* The EventSource API handles reconnection automatically when the
|
|
1358
|
+
* connection is lost, unless explicitly closed.
|
|
1359
|
+
*
|
|
1360
|
+
* @throws Error if EventSource is not supported or connection fails
|
|
1361
|
+
*
|
|
1362
|
+
* @example
|
|
1363
|
+
* ```typescript
|
|
1364
|
+
* await connection.connect();
|
|
1365
|
+
* console.log('Connected to SSE stream');
|
|
1366
|
+
* ```
|
|
1367
|
+
*/
|
|
1368
|
+
async connect() {
|
|
1369
|
+
if (this.eventSource !== null) {
|
|
1370
|
+
throw new Error("Connection already exists");
|
|
1371
|
+
}
|
|
1372
|
+
this.setState("connecting");
|
|
1373
|
+
try {
|
|
1374
|
+
this.eventSource = new EventSource(this.sseConfig.url, {
|
|
1375
|
+
withCredentials: this.sseConfig.withCredentials
|
|
1376
|
+
});
|
|
1377
|
+
await new Promise((resolve, reject) => {
|
|
1378
|
+
if (!this.eventSource) {
|
|
1379
|
+
reject(new Error("EventSource not created"));
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
const onOpen = () => {
|
|
1383
|
+
cleanup();
|
|
1384
|
+
this.setState("connected");
|
|
1385
|
+
this.resetManualDisconnect();
|
|
1386
|
+
this.emit("connect", void 0);
|
|
1387
|
+
resolve();
|
|
1388
|
+
};
|
|
1389
|
+
const onError = () => {
|
|
1390
|
+
cleanup();
|
|
1391
|
+
const error = new Error("Failed to connect to SSE stream");
|
|
1392
|
+
this.emit("error", { error });
|
|
1393
|
+
reject(error);
|
|
1394
|
+
};
|
|
1395
|
+
const cleanup = () => {
|
|
1396
|
+
this.eventSource?.removeEventListener("open", onOpen);
|
|
1397
|
+
this.eventSource?.removeEventListener("error", onError);
|
|
1398
|
+
};
|
|
1399
|
+
this.eventSource.addEventListener("open", onOpen);
|
|
1400
|
+
this.eventSource.addEventListener("error", onError);
|
|
1401
|
+
});
|
|
1402
|
+
this.setupEventListeners();
|
|
1403
|
+
} catch (error) {
|
|
1404
|
+
this.closeEventSource();
|
|
1405
|
+
throw error;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Close SSE connection.
|
|
1410
|
+
*
|
|
1411
|
+
* @remarks
|
|
1412
|
+
* Closes the EventSource and cleans up all event listeners.
|
|
1413
|
+
* Sets the manual disconnect flag to prevent automatic reconnection.
|
|
1414
|
+
*
|
|
1415
|
+
* @example
|
|
1416
|
+
* ```typescript
|
|
1417
|
+
* connection.disconnect();
|
|
1418
|
+
* console.log('Disconnected from SSE stream');
|
|
1419
|
+
* ```
|
|
1420
|
+
*/
|
|
1421
|
+
disconnect() {
|
|
1422
|
+
this.setManualDisconnect();
|
|
1423
|
+
this.closeEventSource();
|
|
1424
|
+
this.handleDisconnect("Manual disconnect");
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Get the last event ID received from the server.
|
|
1428
|
+
*
|
|
1429
|
+
* @returns Last event ID or null if none received
|
|
1430
|
+
*
|
|
1431
|
+
* @remarks
|
|
1432
|
+
* The event ID is used by the EventSource API to resume the stream
|
|
1433
|
+
* from the last received event after a reconnection. The browser
|
|
1434
|
+
* automatically sends this ID in the `Last-Event-ID` header.
|
|
1435
|
+
*
|
|
1436
|
+
* @example
|
|
1437
|
+
* ```typescript
|
|
1438
|
+
* const lastId = connection.getLastEventId();
|
|
1439
|
+
* if (lastId) {
|
|
1440
|
+
* console.log(`Last event: ${lastId}`);
|
|
1441
|
+
* }
|
|
1442
|
+
* ```
|
|
1443
|
+
*/
|
|
1444
|
+
getLastEventId() {
|
|
1445
|
+
return this.lastEventId;
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Setup event listeners for configured event types.
|
|
1449
|
+
*/
|
|
1450
|
+
setupEventListeners() {
|
|
1451
|
+
if (!this.eventSource) return;
|
|
1452
|
+
for (const eventType of this.sseConfig.eventTypes) {
|
|
1453
|
+
this.eventSource.addEventListener(eventType, (event) => {
|
|
1454
|
+
this.handleMessage(event);
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
this.eventSource.addEventListener("error", () => {
|
|
1458
|
+
this.handleError();
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Handle incoming message event.
|
|
1463
|
+
*/
|
|
1464
|
+
handleMessage(event) {
|
|
1465
|
+
if (event.lastEventId) {
|
|
1466
|
+
this.lastEventId = event.lastEventId;
|
|
1467
|
+
}
|
|
1468
|
+
try {
|
|
1469
|
+
const data = JSON.parse(event.data);
|
|
1470
|
+
this.emit("message", { data });
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
const parseError = new Error(
|
|
1473
|
+
`Failed to parse SSE message as JSON: ${event.data}`
|
|
1474
|
+
);
|
|
1475
|
+
this.emit("error", { error: parseError });
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Handle error event from EventSource.
|
|
1480
|
+
*/
|
|
1481
|
+
handleError() {
|
|
1482
|
+
if (this.state === "connected") {
|
|
1483
|
+
const error = new Error("SSE connection error");
|
|
1484
|
+
this.emit("error", { error });
|
|
1485
|
+
if (this.eventSource?.readyState === EventSource.CLOSED) {
|
|
1486
|
+
this.closeEventSource();
|
|
1487
|
+
this.handleDisconnect("Connection closed by server");
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Close EventSource and clean up.
|
|
1493
|
+
*/
|
|
1494
|
+
closeEventSource() {
|
|
1495
|
+
if (this.eventSource) {
|
|
1496
|
+
this.eventSource.close();
|
|
1497
|
+
this.eventSource = null;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
};
|
|
1501
|
+
|
|
1502
|
+
// src/data/streaming/websocket-connection.ts
|
|
1503
|
+
var WebSocketConnection = class extends BaseConnection {
|
|
1504
|
+
ws = null;
|
|
1505
|
+
wsConfig;
|
|
1506
|
+
/**
|
|
1507
|
+
* Create a new WebSocket connection.
|
|
1508
|
+
*
|
|
1509
|
+
* @param config - WebSocket configuration
|
|
1510
|
+
*
|
|
1511
|
+
* @example
|
|
1512
|
+
* ```typescript
|
|
1513
|
+
* const connection = new WebSocketConnection({
|
|
1514
|
+
* url: 'wss://api.example.com/stream',
|
|
1515
|
+
* protocols: ['json', 'v1'],
|
|
1516
|
+
* });
|
|
1517
|
+
* ```
|
|
1518
|
+
*/
|
|
1519
|
+
constructor(config) {
|
|
1520
|
+
super(config);
|
|
1521
|
+
this.wsConfig = {
|
|
1522
|
+
...this.config,
|
|
1523
|
+
protocols: config.protocols
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* Establish WebSocket connection.
|
|
1528
|
+
*
|
|
1529
|
+
* @remarks
|
|
1530
|
+
* Creates a WebSocket and sets up event listeners for:
|
|
1531
|
+
* - Connection open
|
|
1532
|
+
* - Message reception
|
|
1533
|
+
* - Connection close
|
|
1534
|
+
* - Errors
|
|
1535
|
+
*
|
|
1536
|
+
* Unlike EventSource, WebSocket does not have built-in reconnection,
|
|
1537
|
+
* so reconnection is handled manually via the BaseConnection.
|
|
1538
|
+
*
|
|
1539
|
+
* @throws Error if WebSocket is not supported or connection fails
|
|
1540
|
+
*
|
|
1541
|
+
* @example
|
|
1542
|
+
* ```typescript
|
|
1543
|
+
* await connection.connect();
|
|
1544
|
+
* console.log('Connected to WebSocket');
|
|
1545
|
+
* ```
|
|
1546
|
+
*/
|
|
1547
|
+
async connect() {
|
|
1548
|
+
if (this.ws !== null) {
|
|
1549
|
+
throw new Error("Connection already exists");
|
|
1550
|
+
}
|
|
1551
|
+
this.setState("connecting");
|
|
1552
|
+
try {
|
|
1553
|
+
this.ws = this.wsConfig.protocols ? new WebSocket(this.wsConfig.url, this.wsConfig.protocols) : new WebSocket(this.wsConfig.url);
|
|
1554
|
+
await new Promise((resolve, reject) => {
|
|
1555
|
+
if (!this.ws) {
|
|
1556
|
+
reject(new Error("WebSocket not created"));
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
const onOpen = () => {
|
|
1560
|
+
cleanup();
|
|
1561
|
+
this.setState("connected");
|
|
1562
|
+
this.resetManualDisconnect();
|
|
1563
|
+
this.emit("connect", void 0);
|
|
1564
|
+
resolve();
|
|
1565
|
+
};
|
|
1566
|
+
const onError = () => {
|
|
1567
|
+
cleanup();
|
|
1568
|
+
const error = new Error("Failed to connect to WebSocket");
|
|
1569
|
+
this.emit("error", { error });
|
|
1570
|
+
reject(error);
|
|
1571
|
+
};
|
|
1572
|
+
const cleanup = () => {
|
|
1573
|
+
if (this.ws) {
|
|
1574
|
+
this.ws.removeEventListener("open", onOpen);
|
|
1575
|
+
this.ws.removeEventListener("error", onError);
|
|
1576
|
+
}
|
|
1577
|
+
};
|
|
1578
|
+
this.ws.addEventListener("open", onOpen);
|
|
1579
|
+
this.ws.addEventListener("error", onError);
|
|
1580
|
+
});
|
|
1581
|
+
this.setupEventListeners();
|
|
1582
|
+
} catch (error) {
|
|
1583
|
+
this.closeWebSocket();
|
|
1584
|
+
throw error;
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Close WebSocket connection.
|
|
1589
|
+
*
|
|
1590
|
+
* @remarks
|
|
1591
|
+
* Closes the WebSocket with a normal closure code (1000).
|
|
1592
|
+
* Sets the manual disconnect flag to prevent automatic reconnection.
|
|
1593
|
+
*
|
|
1594
|
+
* @example
|
|
1595
|
+
* ```typescript
|
|
1596
|
+
* connection.disconnect();
|
|
1597
|
+
* console.log('Disconnected from WebSocket');
|
|
1598
|
+
* ```
|
|
1599
|
+
*/
|
|
1600
|
+
disconnect() {
|
|
1601
|
+
this.setManualDisconnect();
|
|
1602
|
+
this.closeWebSocket();
|
|
1603
|
+
this.handleDisconnect("Manual disconnect");
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* Send data through WebSocket.
|
|
1607
|
+
*
|
|
1608
|
+
* @param data - Data to send (will be JSON stringified)
|
|
1609
|
+
* @throws Error if not connected
|
|
1610
|
+
*
|
|
1611
|
+
* @remarks
|
|
1612
|
+
* The data is automatically converted to JSON before sending.
|
|
1613
|
+
* Throws an error if called when the connection is not established.
|
|
1614
|
+
*
|
|
1615
|
+
* @example
|
|
1616
|
+
* ```typescript
|
|
1617
|
+
* connection.send({ type: 'ping' });
|
|
1618
|
+
* connection.send({ type: 'subscribe', channel: 'updates' });
|
|
1619
|
+
* ```
|
|
1620
|
+
*/
|
|
1621
|
+
send(data) {
|
|
1622
|
+
if (!this.isConnected() || !this.ws) {
|
|
1623
|
+
throw new Error("Cannot send: not connected");
|
|
1624
|
+
}
|
|
1625
|
+
const message = JSON.stringify(data);
|
|
1626
|
+
this.ws.send(message);
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* Setup WebSocket event listeners.
|
|
1630
|
+
*/
|
|
1631
|
+
setupEventListeners() {
|
|
1632
|
+
if (!this.ws) return;
|
|
1633
|
+
this.ws.addEventListener("message", (event) => {
|
|
1634
|
+
this.handleMessage(event);
|
|
1635
|
+
});
|
|
1636
|
+
this.ws.addEventListener("close", (event) => {
|
|
1637
|
+
this.handleClose(event);
|
|
1638
|
+
});
|
|
1639
|
+
this.ws.addEventListener("error", () => {
|
|
1640
|
+
this.handleError();
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Handle incoming message event.
|
|
1645
|
+
*/
|
|
1646
|
+
handleMessage(event) {
|
|
1647
|
+
try {
|
|
1648
|
+
const data = JSON.parse(event.data);
|
|
1649
|
+
this.emit("message", { data });
|
|
1650
|
+
} catch {
|
|
1651
|
+
this.emit("message", { data: event.data });
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Handle close event from WebSocket.
|
|
1656
|
+
*/
|
|
1657
|
+
handleClose(event) {
|
|
1658
|
+
this.closeWebSocket();
|
|
1659
|
+
const reason = event.reason || `WebSocket closed (code: ${event.code})`;
|
|
1660
|
+
this.handleDisconnect(reason);
|
|
1661
|
+
}
|
|
1662
|
+
/**
|
|
1663
|
+
* Handle error event from WebSocket.
|
|
1664
|
+
*/
|
|
1665
|
+
handleError() {
|
|
1666
|
+
if (this.state === "connecting" || this.state === "connected") {
|
|
1667
|
+
const error = new Error("WebSocket connection error");
|
|
1668
|
+
this.emit("error", { error });
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Close WebSocket and clean up.
|
|
1673
|
+
*/
|
|
1674
|
+
closeWebSocket() {
|
|
1675
|
+
if (this.ws) {
|
|
1676
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
1677
|
+
this.ws.close(1e3, "Normal closure");
|
|
1678
|
+
}
|
|
1679
|
+
this.ws = null;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1683
|
+
|
|
1684
|
+
// src/data/streaming/stream-manager.ts
|
|
1685
|
+
var StreamManager = class {
|
|
1686
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
1687
|
+
/**
|
|
1688
|
+
* Connect to a streaming source.
|
|
1689
|
+
*
|
|
1690
|
+
* @param id - Unique identifier for this connection
|
|
1691
|
+
* @param config - Stream configuration
|
|
1692
|
+
* @throws {Error} If a connection with the given id already exists
|
|
1693
|
+
*
|
|
1694
|
+
* @example
|
|
1695
|
+
* ```typescript
|
|
1696
|
+
* await manager.connect('updates', {
|
|
1697
|
+
* type: 'sse',
|
|
1698
|
+
* url: 'https://api.example.com/stream',
|
|
1699
|
+
* onData: (data) => console.log(data)
|
|
1700
|
+
* });
|
|
1701
|
+
* ```
|
|
1702
|
+
*/
|
|
1703
|
+
async connect(id, config) {
|
|
1704
|
+
if (this.subscriptions.has(id)) {
|
|
1705
|
+
throw new Error(`Stream with id "${id}" already exists`);
|
|
1706
|
+
}
|
|
1707
|
+
const connection = this.createConnection(config);
|
|
1708
|
+
const state = {
|
|
1709
|
+
connectionState: "disconnected",
|
|
1710
|
+
messageCount: 0,
|
|
1711
|
+
lastMessage: null,
|
|
1712
|
+
reconnectAttempts: 0
|
|
1713
|
+
};
|
|
1714
|
+
this.subscriptions.set(id, { config, connection, state });
|
|
1715
|
+
this.setupEventHandlers(connection, config, state);
|
|
1716
|
+
await connection.connect();
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Disconnect a specific stream.
|
|
1720
|
+
*
|
|
1721
|
+
* @param id - Stream identifier
|
|
1722
|
+
*
|
|
1723
|
+
* @example
|
|
1724
|
+
* ```typescript
|
|
1725
|
+
* manager.disconnect('updates');
|
|
1726
|
+
* ```
|
|
1727
|
+
*/
|
|
1728
|
+
disconnect(id) {
|
|
1729
|
+
const subscription = this.subscriptions.get(id);
|
|
1730
|
+
if (!subscription) {
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
subscription.connection.disconnect();
|
|
1734
|
+
this.subscriptions.delete(id);
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* Disconnect all active streams.
|
|
1738
|
+
*
|
|
1739
|
+
* @example
|
|
1740
|
+
* ```typescript
|
|
1741
|
+
* manager.disconnectAll();
|
|
1742
|
+
* ```
|
|
1743
|
+
*/
|
|
1744
|
+
disconnectAll() {
|
|
1745
|
+
for (const id of this.subscriptions.keys()) {
|
|
1746
|
+
this.disconnect(id);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* Get the current state of a stream.
|
|
1751
|
+
*
|
|
1752
|
+
* @param id - Stream identifier
|
|
1753
|
+
* @returns Stream state or null if not found
|
|
1754
|
+
*
|
|
1755
|
+
* @example
|
|
1756
|
+
* ```typescript
|
|
1757
|
+
* const state = manager.getState('updates');
|
|
1758
|
+
* if (state) {
|
|
1759
|
+
* console.log(`State: ${state.connectionState}`);
|
|
1760
|
+
* console.log(`Messages: ${state.messageCount}`);
|
|
1761
|
+
* }
|
|
1762
|
+
* ```
|
|
1763
|
+
*/
|
|
1764
|
+
getState(id) {
|
|
1765
|
+
const subscription = this.subscriptions.get(id);
|
|
1766
|
+
return subscription ? { ...subscription.state } : null;
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Check if a stream is currently connected.
|
|
1770
|
+
*
|
|
1771
|
+
* @param id - Stream identifier
|
|
1772
|
+
* @returns True if connected, false otherwise
|
|
1773
|
+
*
|
|
1774
|
+
* @example
|
|
1775
|
+
* ```typescript
|
|
1776
|
+
* if (manager.isConnected('updates')) {
|
|
1777
|
+
* console.log('Stream is active');
|
|
1778
|
+
* }
|
|
1779
|
+
* ```
|
|
1780
|
+
*/
|
|
1781
|
+
isConnected(id) {
|
|
1782
|
+
const subscription = this.subscriptions.get(id);
|
|
1783
|
+
return subscription ? subscription.connection.isConnected() : false;
|
|
1784
|
+
}
|
|
1785
|
+
/**
|
|
1786
|
+
* Get all active stream IDs.
|
|
1787
|
+
*
|
|
1788
|
+
* @returns Array of active stream identifiers
|
|
1789
|
+
*
|
|
1790
|
+
* @example
|
|
1791
|
+
* ```typescript
|
|
1792
|
+
* const activeStreams = manager.getActiveIds();
|
|
1793
|
+
* console.log(`Active streams: ${activeStreams.join(', ')}`);
|
|
1794
|
+
* ```
|
|
1795
|
+
*/
|
|
1796
|
+
getActiveIds() {
|
|
1797
|
+
return Array.from(this.subscriptions.keys());
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Send data to a WebSocket connection.
|
|
1801
|
+
*
|
|
1802
|
+
* @param id - Stream identifier
|
|
1803
|
+
* @param data - Data to send (will be JSON stringified)
|
|
1804
|
+
* @throws {Error} If stream is not a WebSocket connection or not connected
|
|
1805
|
+
*
|
|
1806
|
+
* @example
|
|
1807
|
+
* ```typescript
|
|
1808
|
+
* manager.send('ws-updates', {
|
|
1809
|
+
* type: 'subscribe',
|
|
1810
|
+
* channels: ['news', 'sports']
|
|
1811
|
+
* });
|
|
1812
|
+
* ```
|
|
1813
|
+
*/
|
|
1814
|
+
send(id, data) {
|
|
1815
|
+
const subscription = this.subscriptions.get(id);
|
|
1816
|
+
if (!subscription) {
|
|
1817
|
+
throw new Error(`Stream "${id}" not found`);
|
|
1818
|
+
}
|
|
1819
|
+
if (!(subscription.connection instanceof WebSocketConnection)) {
|
|
1820
|
+
throw new Error(`Stream "${id}" is not a WebSocket connection`);
|
|
1821
|
+
}
|
|
1822
|
+
subscription.connection.send(data);
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Clean up all resources.
|
|
1826
|
+
*
|
|
1827
|
+
* @example
|
|
1828
|
+
* ```typescript
|
|
1829
|
+
* manager.destroy();
|
|
1830
|
+
* ```
|
|
1831
|
+
*/
|
|
1832
|
+
destroy() {
|
|
1833
|
+
this.disconnectAll();
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Create a connection instance based on config type.
|
|
1837
|
+
*/
|
|
1838
|
+
createConnection(config) {
|
|
1839
|
+
const reconnectConfig = config.reconnect?.enabled !== false ? {
|
|
1840
|
+
reconnect: true,
|
|
1841
|
+
retryConfig: {
|
|
1842
|
+
maxRetries: config.reconnect?.maxRetries ?? 10,
|
|
1843
|
+
initialDelay: config.reconnect?.initialDelay ?? 1e3,
|
|
1844
|
+
maxDelay: config.reconnect?.maxDelay ?? 3e4
|
|
1845
|
+
}
|
|
1846
|
+
} : { reconnect: false };
|
|
1847
|
+
if (config.type === "sse") {
|
|
1848
|
+
const sseConfig = {
|
|
1849
|
+
url: config.url,
|
|
1850
|
+
...reconnectConfig,
|
|
1851
|
+
eventTypes: config.eventTypes
|
|
1852
|
+
};
|
|
1853
|
+
return new SSEConnection(sseConfig);
|
|
1854
|
+
} else {
|
|
1855
|
+
const wsConfig = {
|
|
1856
|
+
url: config.url,
|
|
1857
|
+
...reconnectConfig,
|
|
1858
|
+
protocols: config.protocols
|
|
1859
|
+
};
|
|
1860
|
+
return new WebSocketConnection(wsConfig);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Setup event handlers for a connection.
|
|
1865
|
+
*/
|
|
1866
|
+
setupEventHandlers(connection, config, state) {
|
|
1867
|
+
connection.on("stateChange", ({ to }) => {
|
|
1868
|
+
state.connectionState = to;
|
|
1869
|
+
config.onStateChange?.(to);
|
|
1870
|
+
});
|
|
1871
|
+
connection.on("message", ({ data }) => {
|
|
1872
|
+
state.messageCount++;
|
|
1873
|
+
state.lastMessage = Date.now();
|
|
1874
|
+
if (this.isFeatureCollection(data)) {
|
|
1875
|
+
config.onData(data);
|
|
1876
|
+
} else {
|
|
1877
|
+
const error = new Error(
|
|
1878
|
+
"Received data is not a valid GeoJSON FeatureCollection"
|
|
1879
|
+
);
|
|
1880
|
+
config.onError?.(error);
|
|
1881
|
+
}
|
|
1882
|
+
});
|
|
1883
|
+
connection.on("error", ({ error }) => {
|
|
1884
|
+
config.onError?.(error);
|
|
1885
|
+
});
|
|
1886
|
+
connection.on("reconnecting", () => {
|
|
1887
|
+
state.reconnectAttempts++;
|
|
1888
|
+
});
|
|
1889
|
+
connection.on("reconnected", () => {
|
|
1890
|
+
state.reconnectAttempts = 0;
|
|
1891
|
+
});
|
|
1892
|
+
connection.on("failed", ({ lastError }) => {
|
|
1893
|
+
config.onError?.(lastError);
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* Type guard to check if data is a FeatureCollection.
|
|
1898
|
+
*/
|
|
1899
|
+
isFeatureCollection(data) {
|
|
1900
|
+
return typeof data === "object" && data !== null && "type" in data && data.type === "FeatureCollection" && "features" in data && Array.isArray(data.features);
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
|
|
1904
|
+
// src/data/merge/data-merger.ts
|
|
1905
|
+
var DataMerger = class {
|
|
1906
|
+
/**
|
|
1907
|
+
* Merge two FeatureCollections using the specified strategy.
|
|
1908
|
+
*
|
|
1909
|
+
* @param existing - Existing feature collection
|
|
1910
|
+
* @param incoming - Incoming feature collection to merge
|
|
1911
|
+
* @param options - Merge options including strategy
|
|
1912
|
+
* @returns Merge result with statistics
|
|
1913
|
+
* @throws {Error} If merge strategy requires missing options
|
|
1914
|
+
*
|
|
1915
|
+
* @example
|
|
1916
|
+
* ```typescript
|
|
1917
|
+
* const merger = new DataMerger();
|
|
1918
|
+
*
|
|
1919
|
+
* const result = merger.merge(existingData, newData, {
|
|
1920
|
+
* strategy: 'merge',
|
|
1921
|
+
* updateKey: 'id'
|
|
1922
|
+
* });
|
|
1923
|
+
*
|
|
1924
|
+
* console.log(`Added: ${result.added}, Updated: ${result.updated}`);
|
|
1925
|
+
* console.log(`Total features: ${result.total}`);
|
|
1926
|
+
* ```
|
|
1927
|
+
*/
|
|
1928
|
+
merge(existing, incoming, options) {
|
|
1929
|
+
switch (options.strategy) {
|
|
1930
|
+
case "replace":
|
|
1931
|
+
return this.mergeReplace(existing, incoming);
|
|
1932
|
+
case "merge":
|
|
1933
|
+
return this.mergeMerge(existing, incoming, options);
|
|
1934
|
+
case "append-window":
|
|
1935
|
+
return this.mergeAppendWindow(existing, incoming, options);
|
|
1936
|
+
default:
|
|
1937
|
+
throw new Error(
|
|
1938
|
+
`Unknown merge strategy: ${options.strategy}`
|
|
1939
|
+
);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* Replace strategy: Complete replacement of existing data.
|
|
1944
|
+
*/
|
|
1945
|
+
mergeReplace(existing, incoming) {
|
|
1946
|
+
return {
|
|
1947
|
+
data: incoming,
|
|
1948
|
+
added: incoming.features.length,
|
|
1949
|
+
updated: 0,
|
|
1950
|
+
removed: existing.features.length,
|
|
1951
|
+
total: incoming.features.length
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Merge strategy: Update by key, keep unmatched features.
|
|
1956
|
+
*/
|
|
1957
|
+
mergeMerge(existing, incoming, options) {
|
|
1958
|
+
if (!options.updateKey) {
|
|
1959
|
+
throw new Error("updateKey is required for merge strategy");
|
|
1960
|
+
}
|
|
1961
|
+
const updateKey = options.updateKey;
|
|
1962
|
+
let added = 0;
|
|
1963
|
+
let updated = 0;
|
|
1964
|
+
const existingMap = /* @__PURE__ */ new Map();
|
|
1965
|
+
for (const feature of existing.features) {
|
|
1966
|
+
const key = feature.properties?.[updateKey];
|
|
1967
|
+
if (key !== void 0 && key !== null) {
|
|
1968
|
+
existingMap.set(key, feature);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
for (const feature of incoming.features) {
|
|
1972
|
+
const key = feature.properties?.[updateKey];
|
|
1973
|
+
if (key !== void 0 && key !== null) {
|
|
1974
|
+
if (existingMap.has(key)) {
|
|
1975
|
+
updated++;
|
|
1976
|
+
} else {
|
|
1977
|
+
added++;
|
|
1978
|
+
}
|
|
1979
|
+
existingMap.set(key, feature);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
const features = Array.from(existingMap.values());
|
|
1983
|
+
return {
|
|
1984
|
+
data: {
|
|
1985
|
+
type: "FeatureCollection",
|
|
1986
|
+
features
|
|
1987
|
+
},
|
|
1988
|
+
added,
|
|
1989
|
+
updated,
|
|
1990
|
+
removed: 0,
|
|
1991
|
+
total: features.length
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
/**
|
|
1995
|
+
* Append-window strategy: Add with time/size limits.
|
|
1996
|
+
*/
|
|
1997
|
+
mergeAppendWindow(existing, incoming, options) {
|
|
1998
|
+
const initialCount = existing.features.length;
|
|
1999
|
+
let features = [...existing.features, ...incoming.features];
|
|
2000
|
+
if (options.windowDuration && options.timestampField) {
|
|
2001
|
+
const cutoffTime = Date.now() - options.windowDuration;
|
|
2002
|
+
features = features.filter((feature) => {
|
|
2003
|
+
const timestamp = feature.properties?.[options.timestampField];
|
|
2004
|
+
if (typeof timestamp === "number") {
|
|
2005
|
+
return timestamp >= cutoffTime;
|
|
2006
|
+
}
|
|
2007
|
+
return true;
|
|
2008
|
+
});
|
|
2009
|
+
features.sort((a, b) => {
|
|
2010
|
+
const timeA = a.properties?.[options.timestampField] ?? 0;
|
|
2011
|
+
const timeB = b.properties?.[options.timestampField] ?? 0;
|
|
2012
|
+
return timeB - timeA;
|
|
2013
|
+
});
|
|
2014
|
+
if (options.windowSize && features.length > options.windowSize) {
|
|
2015
|
+
features = features.slice(0, options.windowSize);
|
|
2016
|
+
}
|
|
2017
|
+
const removed2 = initialCount + incoming.features.length - features.length;
|
|
2018
|
+
return {
|
|
2019
|
+
data: {
|
|
2020
|
+
type: "FeatureCollection",
|
|
2021
|
+
features
|
|
2022
|
+
},
|
|
2023
|
+
added: incoming.features.length,
|
|
2024
|
+
updated: 0,
|
|
2025
|
+
removed: removed2,
|
|
2026
|
+
total: features.length
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
if (options.windowSize) {
|
|
2030
|
+
if (options.timestampField) {
|
|
2031
|
+
features.sort((a, b) => {
|
|
2032
|
+
const timeA = a.properties?.[options.timestampField] ?? 0;
|
|
2033
|
+
const timeB = b.properties?.[options.timestampField] ?? 0;
|
|
2034
|
+
return timeB - timeA;
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
if (features.length > options.windowSize) {
|
|
2038
|
+
features = features.slice(0, options.windowSize);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
const removed = initialCount + incoming.features.length - features.length;
|
|
2042
|
+
return {
|
|
2043
|
+
data: {
|
|
2044
|
+
type: "FeatureCollection",
|
|
2045
|
+
features
|
|
2046
|
+
},
|
|
2047
|
+
added: incoming.features.length,
|
|
2048
|
+
updated: 0,
|
|
2049
|
+
removed,
|
|
2050
|
+
total: features.length
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
};
|
|
2054
|
+
|
|
2055
|
+
// src/ui/loading-manager.ts
|
|
2056
|
+
var LoadingManager = class _LoadingManager extends EventEmitter {
|
|
2057
|
+
config;
|
|
2058
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
2059
|
+
static DEFAULT_CONFIG = {
|
|
2060
|
+
showUI: false,
|
|
2061
|
+
messages: {
|
|
2062
|
+
loading: "Loading...",
|
|
2063
|
+
error: "Failed to load data",
|
|
2064
|
+
retry: "Retrying..."
|
|
2065
|
+
},
|
|
2066
|
+
spinnerStyle: "circle",
|
|
2067
|
+
minDisplayTime: 300
|
|
2068
|
+
};
|
|
2069
|
+
/**
|
|
2070
|
+
* Create a new LoadingManager.
|
|
2071
|
+
*
|
|
2072
|
+
* @param config - Loading manager configuration
|
|
2073
|
+
*
|
|
2074
|
+
* @example
|
|
2075
|
+
* ```typescript
|
|
2076
|
+
* const manager = new LoadingManager({
|
|
2077
|
+
* showUI: true,
|
|
2078
|
+
* messages: {
|
|
2079
|
+
* loading: 'Fetching data...',
|
|
2080
|
+
* error: 'Could not load data'
|
|
2081
|
+
* },
|
|
2082
|
+
* spinnerStyle: 'dots',
|
|
2083
|
+
* minDisplayTime: 500
|
|
2084
|
+
* });
|
|
2085
|
+
* ```
|
|
2086
|
+
*/
|
|
2087
|
+
constructor(config) {
|
|
2088
|
+
super();
|
|
2089
|
+
this.config = {
|
|
2090
|
+
..._LoadingManager.DEFAULT_CONFIG,
|
|
2091
|
+
...config,
|
|
2092
|
+
messages: {
|
|
2093
|
+
..._LoadingManager.DEFAULT_CONFIG.messages,
|
|
2094
|
+
...config?.messages
|
|
2095
|
+
}
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
/**
|
|
2099
|
+
* Show loading state for a layer.
|
|
2100
|
+
*
|
|
2101
|
+
* @param layerId - Layer identifier
|
|
2102
|
+
* @param container - Container element for UI overlay
|
|
2103
|
+
* @param message - Custom loading message
|
|
2104
|
+
*
|
|
2105
|
+
* @example
|
|
2106
|
+
* ```typescript
|
|
2107
|
+
* const container = document.getElementById('map');
|
|
2108
|
+
* manager.showLoading('earthquakes', container, 'Loading earthquake data...');
|
|
2109
|
+
* ```
|
|
2110
|
+
*/
|
|
2111
|
+
showLoading(layerId, container, message) {
|
|
2112
|
+
const existingSub = this.subscriptions.get(layerId);
|
|
2113
|
+
if (existingSub?.state.isLoading) {
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
const state = {
|
|
2117
|
+
isLoading: true,
|
|
2118
|
+
startTime: Date.now(),
|
|
2119
|
+
message: message || this.config.messages.loading
|
|
2120
|
+
};
|
|
2121
|
+
let overlay = null;
|
|
2122
|
+
if (this.config.showUI) {
|
|
2123
|
+
overlay = this.createLoadingOverlay(
|
|
2124
|
+
state.message || this.config.messages.loading || "Loading..."
|
|
2125
|
+
);
|
|
2126
|
+
container.style.position = "relative";
|
|
2127
|
+
container.appendChild(overlay);
|
|
2128
|
+
}
|
|
2129
|
+
this.subscriptions.set(layerId, {
|
|
2130
|
+
state,
|
|
2131
|
+
container,
|
|
2132
|
+
overlay,
|
|
2133
|
+
minDisplayTimer: null
|
|
2134
|
+
});
|
|
2135
|
+
this.emit("loading:start", { layerId, message: state.message });
|
|
2136
|
+
}
|
|
2137
|
+
/**
|
|
2138
|
+
* Hide loading state for a layer.
|
|
2139
|
+
*
|
|
2140
|
+
* @param layerId - Layer identifier
|
|
2141
|
+
* @param result - Optional result information
|
|
2142
|
+
*
|
|
2143
|
+
* @example
|
|
2144
|
+
* ```typescript
|
|
2145
|
+
* manager.hideLoading('earthquakes', { fromCache: true });
|
|
2146
|
+
* ```
|
|
2147
|
+
*/
|
|
2148
|
+
hideLoading(layerId, result) {
|
|
2149
|
+
const subscription = this.subscriptions.get(layerId);
|
|
2150
|
+
if (!subscription?.state.isLoading) {
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
const duration = Date.now() - (subscription.state.startTime || Date.now());
|
|
2154
|
+
const timeRemaining = Math.max(0, this.config.minDisplayTime - duration);
|
|
2155
|
+
const cleanup = () => {
|
|
2156
|
+
if (subscription.overlay) {
|
|
2157
|
+
subscription.overlay.remove();
|
|
2158
|
+
}
|
|
2159
|
+
subscription.state.isLoading = false;
|
|
2160
|
+
subscription.state.startTime = null;
|
|
2161
|
+
subscription.state.error = void 0;
|
|
2162
|
+
subscription.state.retryAttempt = void 0;
|
|
2163
|
+
this.emit("loading:complete", {
|
|
2164
|
+
layerId,
|
|
2165
|
+
duration,
|
|
2166
|
+
fromCache: result?.fromCache ?? false
|
|
2167
|
+
});
|
|
2168
|
+
};
|
|
2169
|
+
if (timeRemaining > 0 && subscription.overlay) {
|
|
2170
|
+
subscription.minDisplayTimer = window.setTimeout(cleanup, timeRemaining);
|
|
2171
|
+
} else {
|
|
2172
|
+
cleanup();
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Show error state for a layer.
|
|
2177
|
+
*
|
|
2178
|
+
* @param layerId - Layer identifier
|
|
2179
|
+
* @param container - Container element for UI overlay
|
|
2180
|
+
* @param error - Error that occurred
|
|
2181
|
+
* @param onRetry - Optional retry callback
|
|
2182
|
+
*
|
|
2183
|
+
* @example
|
|
2184
|
+
* ```typescript
|
|
2185
|
+
* manager.showError('earthquakes', container, error, () => {
|
|
2186
|
+
* // Retry loading
|
|
2187
|
+
* fetchData();
|
|
2188
|
+
* });
|
|
2189
|
+
* ```
|
|
2190
|
+
*/
|
|
2191
|
+
showError(layerId, container, error, onRetry) {
|
|
2192
|
+
const subscription = this.subscriptions.get(layerId);
|
|
2193
|
+
const state = subscription?.state || {
|
|
2194
|
+
isLoading: false,
|
|
2195
|
+
startTime: null
|
|
2196
|
+
};
|
|
2197
|
+
state.error = error;
|
|
2198
|
+
state.isLoading = false;
|
|
2199
|
+
if (subscription?.overlay) {
|
|
2200
|
+
subscription.overlay.remove();
|
|
2201
|
+
}
|
|
2202
|
+
let overlay = null;
|
|
2203
|
+
if (this.config.showUI) {
|
|
2204
|
+
overlay = this.createErrorOverlay(
|
|
2205
|
+
error.message || this.config.messages.error || "An error occurred",
|
|
2206
|
+
onRetry
|
|
2207
|
+
);
|
|
2208
|
+
container.style.position = "relative";
|
|
2209
|
+
container.appendChild(overlay);
|
|
2210
|
+
}
|
|
2211
|
+
this.subscriptions.set(layerId, {
|
|
2212
|
+
state,
|
|
2213
|
+
container,
|
|
2214
|
+
overlay,
|
|
2215
|
+
minDisplayTimer: null
|
|
2216
|
+
});
|
|
2217
|
+
this.emit("loading:error", {
|
|
2218
|
+
layerId,
|
|
2219
|
+
error,
|
|
2220
|
+
retrying: !!onRetry
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
/**
|
|
2224
|
+
* Show retrying state for a layer.
|
|
2225
|
+
*
|
|
2226
|
+
* @param layerId - Layer identifier
|
|
2227
|
+
* @param attempt - Current retry attempt number
|
|
2228
|
+
* @param delay - Delay before retry in milliseconds
|
|
2229
|
+
*
|
|
2230
|
+
* @example
|
|
2231
|
+
* ```typescript
|
|
2232
|
+
* manager.showRetrying('earthquakes', 2, 2000);
|
|
2233
|
+
* ```
|
|
2234
|
+
*/
|
|
2235
|
+
showRetrying(layerId, attempt, delay) {
|
|
2236
|
+
const subscription = this.subscriptions.get(layerId);
|
|
2237
|
+
if (subscription) {
|
|
2238
|
+
subscription.state.retryAttempt = attempt;
|
|
2239
|
+
if (subscription.overlay && this.config.showUI) {
|
|
2240
|
+
const message = `${this.config.messages.retry} (attempt ${attempt})`;
|
|
2241
|
+
const newOverlay = this.createLoadingOverlay(message);
|
|
2242
|
+
subscription.overlay.replaceWith(newOverlay);
|
|
2243
|
+
subscription.overlay = newOverlay;
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
this.emit("loading:retry", { layerId, attempt, delay });
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* Get loading state for a layer.
|
|
2250
|
+
*
|
|
2251
|
+
* @param layerId - Layer identifier
|
|
2252
|
+
* @returns Loading state or null if not found
|
|
2253
|
+
*
|
|
2254
|
+
* @example
|
|
2255
|
+
* ```typescript
|
|
2256
|
+
* const state = manager.getState('earthquakes');
|
|
2257
|
+
* if (state?.isLoading) {
|
|
2258
|
+
* console.log('Still loading...');
|
|
2259
|
+
* }
|
|
2260
|
+
* ```
|
|
2261
|
+
*/
|
|
2262
|
+
getState(layerId) {
|
|
2263
|
+
const subscription = this.subscriptions.get(layerId);
|
|
2264
|
+
return subscription ? { ...subscription.state } : null;
|
|
2265
|
+
}
|
|
2266
|
+
/**
|
|
2267
|
+
* Check if a layer is currently loading.
|
|
2268
|
+
*
|
|
2269
|
+
* @param layerId - Layer identifier
|
|
2270
|
+
* @returns True if loading, false otherwise
|
|
2271
|
+
*
|
|
2272
|
+
* @example
|
|
2273
|
+
* ```typescript
|
|
2274
|
+
* if (manager.isLoading('earthquakes')) {
|
|
2275
|
+
* console.log('Loading in progress');
|
|
2276
|
+
* }
|
|
2277
|
+
* ```
|
|
2278
|
+
*/
|
|
2279
|
+
isLoading(layerId) {
|
|
2280
|
+
return this.subscriptions.get(layerId)?.state.isLoading ?? false;
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* Clear all loading states and UI.
|
|
2284
|
+
*
|
|
2285
|
+
* @example
|
|
2286
|
+
* ```typescript
|
|
2287
|
+
* manager.clearAll();
|
|
2288
|
+
* ```
|
|
2289
|
+
*/
|
|
2290
|
+
clearAll() {
|
|
2291
|
+
for (const [layerId, subscription] of this.subscriptions.entries()) {
|
|
2292
|
+
if (subscription.minDisplayTimer) {
|
|
2293
|
+
clearTimeout(subscription.minDisplayTimer);
|
|
2294
|
+
}
|
|
2295
|
+
if (subscription.overlay) {
|
|
2296
|
+
subscription.overlay.remove();
|
|
2297
|
+
}
|
|
2298
|
+
this.subscriptions.delete(layerId);
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
/**
|
|
2302
|
+
* Clean up all resources.
|
|
2303
|
+
*
|
|
2304
|
+
* @example
|
|
2305
|
+
* ```typescript
|
|
2306
|
+
* manager.destroy();
|
|
2307
|
+
* ```
|
|
2308
|
+
*/
|
|
2309
|
+
destroy() {
|
|
2310
|
+
this.clearAll();
|
|
2311
|
+
this.removeAllListeners();
|
|
2312
|
+
}
|
|
2313
|
+
/**
|
|
2314
|
+
* Create loading overlay element.
|
|
2315
|
+
*/
|
|
2316
|
+
createLoadingOverlay(message) {
|
|
2317
|
+
const overlay = document.createElement("div");
|
|
2318
|
+
overlay.className = "mly-loading-overlay";
|
|
2319
|
+
const content = document.createElement("div");
|
|
2320
|
+
content.className = "mly-loading-content";
|
|
2321
|
+
const spinner = document.createElement("div");
|
|
2322
|
+
spinner.className = `mly-spinner mly-spinner--${this.config.spinnerStyle}`;
|
|
2323
|
+
const text = document.createElement("div");
|
|
2324
|
+
text.className = "mly-loading-text";
|
|
2325
|
+
text.textContent = message;
|
|
2326
|
+
content.appendChild(spinner);
|
|
2327
|
+
content.appendChild(text);
|
|
2328
|
+
overlay.appendChild(content);
|
|
2329
|
+
return overlay;
|
|
2330
|
+
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Create error overlay element.
|
|
2333
|
+
*/
|
|
2334
|
+
createErrorOverlay(message, onRetry) {
|
|
2335
|
+
const overlay = document.createElement("div");
|
|
2336
|
+
overlay.className = "mly-loading-overlay mly-loading-overlay--error";
|
|
2337
|
+
const content = document.createElement("div");
|
|
2338
|
+
content.className = "mly-error-content";
|
|
2339
|
+
const icon = document.createElement("div");
|
|
2340
|
+
icon.className = "mly-error-icon";
|
|
2341
|
+
icon.textContent = "\u26A0";
|
|
2342
|
+
const text = document.createElement("div");
|
|
2343
|
+
text.className = "mly-error-text";
|
|
2344
|
+
text.textContent = message;
|
|
2345
|
+
content.appendChild(icon);
|
|
2346
|
+
content.appendChild(text);
|
|
2347
|
+
if (onRetry) {
|
|
2348
|
+
const button = document.createElement("button");
|
|
2349
|
+
button.className = "mly-retry-button";
|
|
2350
|
+
button.textContent = "Retry";
|
|
2351
|
+
button.onclick = () => {
|
|
2352
|
+
overlay.remove();
|
|
2353
|
+
onRetry();
|
|
2354
|
+
};
|
|
2355
|
+
content.appendChild(button);
|
|
2356
|
+
}
|
|
2357
|
+
overlay.appendChild(content);
|
|
2358
|
+
return overlay;
|
|
2359
|
+
}
|
|
2360
|
+
};
|
|
2361
|
+
|
|
2362
|
+
// src/renderer/layer-manager.ts
|
|
2363
|
+
var LayerManager = class {
|
|
2364
|
+
map;
|
|
2365
|
+
callbacks;
|
|
2366
|
+
dataFetcher;
|
|
2367
|
+
pollingManager;
|
|
2368
|
+
streamManager;
|
|
2369
|
+
dataMerger;
|
|
2370
|
+
loadingManager;
|
|
2371
|
+
sourceData;
|
|
2372
|
+
layerToSource;
|
|
2373
|
+
// Legacy support (deprecated)
|
|
2374
|
+
refreshIntervals;
|
|
2375
|
+
abortControllers;
|
|
2376
|
+
constructor(map, callbacks) {
|
|
2377
|
+
this.map = map;
|
|
2378
|
+
this.callbacks = callbacks || {};
|
|
2379
|
+
this.dataFetcher = new DataFetcher();
|
|
2380
|
+
this.pollingManager = new PollingManager();
|
|
2381
|
+
this.streamManager = new StreamManager();
|
|
2382
|
+
this.dataMerger = new DataMerger();
|
|
2383
|
+
this.loadingManager = new LoadingManager({ showUI: false });
|
|
2384
|
+
this.sourceData = /* @__PURE__ */ new Map();
|
|
2385
|
+
this.layerToSource = /* @__PURE__ */ new Map();
|
|
2386
|
+
this.refreshIntervals = /* @__PURE__ */ new Map();
|
|
2387
|
+
this.abortControllers = /* @__PURE__ */ new Map();
|
|
2388
|
+
}
|
|
2389
|
+
async addLayer(layer) {
|
|
2390
|
+
const sourceId = `${layer.id}-source`;
|
|
2391
|
+
this.layerToSource.set(layer.id, sourceId);
|
|
2392
|
+
await this.addSource(sourceId, layer);
|
|
2393
|
+
const layerSpec = {
|
|
2394
|
+
id: layer.id,
|
|
2395
|
+
type: layer.type,
|
|
2396
|
+
source: sourceId
|
|
2397
|
+
};
|
|
2398
|
+
if ("paint" in layer && layer.paint) layerSpec.paint = layer.paint;
|
|
2399
|
+
if ("layout" in layer && layer.layout) layerSpec.layout = layer.layout;
|
|
2400
|
+
if ("source-layer" in layer && layer["source-layer"])
|
|
2401
|
+
layerSpec["source-layer"] = layer["source-layer"];
|
|
2402
|
+
if (layer.minzoom !== void 0) layerSpec.minzoom = layer.minzoom;
|
|
2403
|
+
if (layer.maxzoom !== void 0) layerSpec.maxzoom = layer.maxzoom;
|
|
2404
|
+
if (layer.filter) layerSpec.filter = layer.filter;
|
|
2405
|
+
if (layer.visible === false) {
|
|
2406
|
+
layerSpec.layout = layerSpec.layout || {};
|
|
2407
|
+
layerSpec.layout.visibility = "none";
|
|
2408
|
+
}
|
|
2409
|
+
this.map.addLayer(layerSpec, layer.before);
|
|
2410
|
+
if (typeof layer.source === "object" && layer.source !== null) {
|
|
2411
|
+
const sourceObj = layer.source;
|
|
2412
|
+
if (sourceObj.type === "geojson") {
|
|
2413
|
+
if (sourceObj.refresh || sourceObj.refreshInterval) {
|
|
2414
|
+
await this.setupDataUpdates(layer.id, sourceId, sourceObj);
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
async addSource(sourceId, layer) {
|
|
2420
|
+
if (typeof layer.source === "string") {
|
|
2421
|
+
if (!this.map.getSource(layer.source)) {
|
|
2422
|
+
throw new Error(`Source reference '${layer.source}' not found`);
|
|
2423
|
+
}
|
|
2424
|
+
return;
|
|
2425
|
+
}
|
|
2426
|
+
const source = layer.source;
|
|
2427
|
+
if (source.type === "geojson") {
|
|
2428
|
+
const geojsonSource = source;
|
|
2429
|
+
if (geojsonSource.url) {
|
|
2430
|
+
await this.addGeoJSONSourceFromURL(sourceId, layer.id, geojsonSource);
|
|
2431
|
+
} else if (geojsonSource.data) {
|
|
2432
|
+
this.map.addSource(sourceId, {
|
|
2433
|
+
type: "geojson",
|
|
2434
|
+
data: geojsonSource.data,
|
|
2435
|
+
cluster: geojsonSource.cluster,
|
|
2436
|
+
clusterRadius: geojsonSource.clusterRadius,
|
|
2437
|
+
clusterMaxZoom: geojsonSource.clusterMaxZoom,
|
|
2438
|
+
clusterMinPoints: geojsonSource.clusterMinPoints,
|
|
2439
|
+
clusterProperties: geojsonSource.clusterProperties
|
|
2440
|
+
});
|
|
2441
|
+
} else if (geojsonSource.stream) {
|
|
2442
|
+
this.map.addSource(sourceId, {
|
|
2443
|
+
type: "geojson",
|
|
2444
|
+
data: { type: "FeatureCollection", features: [] }
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
} else if (source.type === "vector") {
|
|
2448
|
+
const vectorSource = source;
|
|
2449
|
+
const vectorSpec = { type: "vector" };
|
|
2450
|
+
if (vectorSource.url) vectorSpec.url = vectorSource.url;
|
|
2451
|
+
if (vectorSource.tiles) vectorSpec.tiles = vectorSource.tiles;
|
|
2452
|
+
if (vectorSource.minzoom !== void 0)
|
|
2453
|
+
vectorSpec.minzoom = vectorSource.minzoom;
|
|
2454
|
+
if (vectorSource.maxzoom !== void 0)
|
|
2455
|
+
vectorSpec.maxzoom = vectorSource.maxzoom;
|
|
2456
|
+
if (vectorSource.bounds) vectorSpec.bounds = vectorSource.bounds;
|
|
2457
|
+
if (vectorSource.attribution)
|
|
2458
|
+
vectorSpec.attribution = vectorSource.attribution;
|
|
2459
|
+
this.map.addSource(sourceId, vectorSpec);
|
|
2460
|
+
} else if (source.type === "raster") {
|
|
2461
|
+
const rasterSource = source;
|
|
2462
|
+
const rasterSpec = { type: "raster" };
|
|
2463
|
+
if (rasterSource.url) rasterSpec.url = rasterSource.url;
|
|
2464
|
+
if (rasterSource.tiles) rasterSpec.tiles = rasterSource.tiles;
|
|
2465
|
+
if (rasterSource.tileSize !== void 0)
|
|
2466
|
+
rasterSpec.tileSize = rasterSource.tileSize;
|
|
2467
|
+
if (rasterSource.minzoom !== void 0)
|
|
2468
|
+
rasterSpec.minzoom = rasterSource.minzoom;
|
|
2469
|
+
if (rasterSource.maxzoom !== void 0)
|
|
2470
|
+
rasterSpec.maxzoom = rasterSource.maxzoom;
|
|
2471
|
+
if (rasterSource.bounds) rasterSpec.bounds = rasterSource.bounds;
|
|
2472
|
+
if (rasterSource.attribution)
|
|
2473
|
+
rasterSpec.attribution = rasterSource.attribution;
|
|
2474
|
+
this.map.addSource(sourceId, rasterSpec);
|
|
2475
|
+
} else if (source.type === "image") {
|
|
2476
|
+
const imageSource = source;
|
|
2477
|
+
this.map.addSource(sourceId, {
|
|
2478
|
+
type: "image",
|
|
2479
|
+
url: imageSource.url,
|
|
2480
|
+
coordinates: imageSource.coordinates
|
|
2481
|
+
});
|
|
2482
|
+
} else if (source.type === "video") {
|
|
2483
|
+
const videoSource = source;
|
|
2484
|
+
this.map.addSource(sourceId, {
|
|
2485
|
+
type: "video",
|
|
2486
|
+
urls: videoSource.urls,
|
|
2487
|
+
coordinates: videoSource.coordinates
|
|
2488
|
+
});
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
async addGeoJSONSourceFromURL(sourceId, layerId, config) {
|
|
2492
|
+
let initialData = {
|
|
2493
|
+
type: "FeatureCollection",
|
|
2494
|
+
features: []
|
|
2495
|
+
};
|
|
2496
|
+
if (config.prefetchedData) {
|
|
2497
|
+
initialData = config.prefetchedData;
|
|
2498
|
+
} else if (config.data) {
|
|
2499
|
+
initialData = config.data;
|
|
2500
|
+
}
|
|
2501
|
+
this.map.addSource(sourceId, {
|
|
2502
|
+
type: "geojson",
|
|
2503
|
+
data: initialData,
|
|
2504
|
+
cluster: config.cluster,
|
|
2505
|
+
clusterRadius: config.clusterRadius,
|
|
2506
|
+
clusterMaxZoom: config.clusterMaxZoom,
|
|
2507
|
+
clusterMinPoints: config.clusterMinPoints,
|
|
2508
|
+
clusterProperties: config.clusterProperties
|
|
2509
|
+
});
|
|
2510
|
+
this.sourceData.set(sourceId, initialData);
|
|
2511
|
+
if (config.url && !config.prefetchedData) {
|
|
2512
|
+
this.callbacks.onDataLoading?.(layerId);
|
|
2513
|
+
try {
|
|
2514
|
+
const cacheEnabled = config.cache?.enabled ?? true;
|
|
2515
|
+
const cacheTTL = config.cache?.ttl;
|
|
2516
|
+
const result = await this.dataFetcher.fetch(config.url, {
|
|
2517
|
+
skipCache: !cacheEnabled,
|
|
2518
|
+
ttl: cacheTTL
|
|
2519
|
+
});
|
|
2520
|
+
const data = result.data;
|
|
2521
|
+
this.sourceData.set(sourceId, data);
|
|
2522
|
+
const source = this.map.getSource(sourceId);
|
|
2523
|
+
if (source?.setData) {
|
|
2524
|
+
source.setData(data);
|
|
2525
|
+
}
|
|
2526
|
+
this.callbacks.onDataLoaded?.(layerId, data.features.length);
|
|
2527
|
+
} catch (error) {
|
|
2528
|
+
this.callbacks.onDataError?.(layerId, error);
|
|
2529
|
+
}
|
|
2530
|
+
} else if (config.prefetchedData) {
|
|
2531
|
+
this.callbacks.onDataLoaded?.(layerId, initialData.features.length);
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
/**
|
|
2535
|
+
* Setup polling and/or streaming for a GeoJSON source
|
|
2536
|
+
*/
|
|
2537
|
+
async setupDataUpdates(layerId, sourceId, config) {
|
|
2538
|
+
if (config.stream) {
|
|
2539
|
+
const streamConfig = config.stream;
|
|
2540
|
+
await this.streamManager.connect(layerId, {
|
|
2541
|
+
type: streamConfig.type,
|
|
2542
|
+
url: streamConfig.url || config.url,
|
|
2543
|
+
onData: (data) => {
|
|
2544
|
+
this.handleDataUpdate(sourceId, layerId, data, {
|
|
2545
|
+
strategy: config.refresh?.updateStrategy || config.updateStrategy || "replace",
|
|
2546
|
+
updateKey: config.refresh?.updateKey || config.updateKey,
|
|
2547
|
+
windowSize: config.refresh?.windowSize,
|
|
2548
|
+
windowDuration: config.refresh?.windowDuration,
|
|
2549
|
+
timestampField: config.refresh?.timestampField
|
|
2550
|
+
});
|
|
2551
|
+
},
|
|
2552
|
+
onError: (error) => {
|
|
2553
|
+
this.callbacks.onDataError?.(layerId, error);
|
|
2554
|
+
},
|
|
2555
|
+
reconnect: {
|
|
2556
|
+
enabled: streamConfig.reconnect !== false,
|
|
2557
|
+
maxRetries: streamConfig.reconnectMaxAttempts,
|
|
2558
|
+
initialDelay: streamConfig.reconnectDelay,
|
|
2559
|
+
maxDelay: streamConfig.reconnectMaxDelay
|
|
2560
|
+
},
|
|
2561
|
+
eventTypes: streamConfig.eventTypes,
|
|
2562
|
+
protocols: streamConfig.protocols
|
|
2563
|
+
});
|
|
2564
|
+
}
|
|
2565
|
+
const refreshInterval = config.refresh?.refreshInterval || config.refreshInterval;
|
|
2566
|
+
if (refreshInterval && config.url) {
|
|
2567
|
+
const url = config.url;
|
|
2568
|
+
const cacheEnabled = config.cache?.enabled ?? true;
|
|
2569
|
+
const cacheTTL = config.cache?.ttl;
|
|
2570
|
+
await this.pollingManager.start(layerId, {
|
|
2571
|
+
interval: refreshInterval,
|
|
2572
|
+
onTick: async () => {
|
|
2573
|
+
const result = await this.dataFetcher.fetch(url, {
|
|
2574
|
+
skipCache: !cacheEnabled,
|
|
2575
|
+
ttl: cacheTTL
|
|
2576
|
+
});
|
|
2577
|
+
this.handleDataUpdate(sourceId, layerId, result.data, {
|
|
2578
|
+
strategy: config.refresh?.updateStrategy || config.updateStrategy || "replace",
|
|
2579
|
+
updateKey: config.refresh?.updateKey || config.updateKey,
|
|
2580
|
+
windowSize: config.refresh?.windowSize,
|
|
2581
|
+
windowDuration: config.refresh?.windowDuration,
|
|
2582
|
+
timestampField: config.refresh?.timestampField
|
|
2583
|
+
});
|
|
2584
|
+
},
|
|
2585
|
+
onError: (error) => {
|
|
2586
|
+
this.callbacks.onDataError?.(layerId, error);
|
|
2587
|
+
}
|
|
2588
|
+
});
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
/**
|
|
2592
|
+
* Handle incoming data updates with merge strategy
|
|
2593
|
+
*/
|
|
2594
|
+
handleDataUpdate(sourceId, layerId, incoming, options) {
|
|
2595
|
+
const existing = this.sourceData.get(sourceId) || {
|
|
2596
|
+
type: "FeatureCollection",
|
|
2597
|
+
features: []
|
|
2598
|
+
};
|
|
2599
|
+
const mergeResult = this.dataMerger.merge(existing, incoming, options);
|
|
2600
|
+
this.sourceData.set(sourceId, mergeResult.data);
|
|
2601
|
+
const source = this.map.getSource(sourceId);
|
|
2602
|
+
if (source?.setData) {
|
|
2603
|
+
source.setData(mergeResult.data);
|
|
2604
|
+
}
|
|
2605
|
+
this.callbacks.onDataLoaded?.(layerId, mergeResult.total);
|
|
2606
|
+
}
|
|
2607
|
+
/**
|
|
2608
|
+
* Pause data refresh for a layer (polling)
|
|
2609
|
+
*/
|
|
2610
|
+
pauseRefresh(layerId) {
|
|
2611
|
+
this.pollingManager.pause(layerId);
|
|
2612
|
+
}
|
|
2613
|
+
/**
|
|
2614
|
+
* Resume data refresh for a layer (polling)
|
|
2615
|
+
*/
|
|
2616
|
+
resumeRefresh(layerId) {
|
|
2617
|
+
this.pollingManager.resume(layerId);
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* Force immediate refresh for a layer (polling)
|
|
2621
|
+
*/
|
|
2622
|
+
async refreshNow(layerId) {
|
|
2623
|
+
await this.pollingManager.triggerNow(layerId);
|
|
2624
|
+
}
|
|
2625
|
+
/**
|
|
2626
|
+
* Disconnect streaming connection for a layer
|
|
2627
|
+
*/
|
|
2628
|
+
disconnectStream(layerId) {
|
|
2629
|
+
this.streamManager.disconnect(layerId);
|
|
2630
|
+
}
|
|
2631
|
+
removeLayer(layerId) {
|
|
2632
|
+
this.pollingManager.stop(layerId);
|
|
2633
|
+
this.streamManager.disconnect(layerId);
|
|
2634
|
+
this.loadingManager.hideLoading(layerId);
|
|
2635
|
+
this.stopRefreshInterval(layerId);
|
|
2636
|
+
const controller = this.abortControllers.get(layerId);
|
|
2637
|
+
if (controller) {
|
|
2638
|
+
controller.abort();
|
|
2639
|
+
this.abortControllers.delete(layerId);
|
|
2640
|
+
}
|
|
2641
|
+
if (this.map.getLayer(layerId)) this.map.removeLayer(layerId);
|
|
2642
|
+
const sourceId = this.layerToSource.get(layerId) || `${layerId}-source`;
|
|
2643
|
+
if (this.map.getSource(sourceId)) this.map.removeSource(sourceId);
|
|
2644
|
+
this.sourceData.delete(sourceId);
|
|
2645
|
+
this.layerToSource.delete(layerId);
|
|
2646
|
+
}
|
|
2647
|
+
setVisibility(layerId, visible) {
|
|
2648
|
+
if (!this.map.getLayer(layerId)) return;
|
|
2649
|
+
this.map.setLayoutProperty(
|
|
2650
|
+
layerId,
|
|
2651
|
+
"visibility",
|
|
2652
|
+
visible ? "visible" : "none"
|
|
2653
|
+
);
|
|
2654
|
+
}
|
|
2655
|
+
updateData(layerId, data) {
|
|
2656
|
+
const sourceId = `${layerId}-source`;
|
|
2657
|
+
const source = this.map.getSource(sourceId);
|
|
2658
|
+
if (source && source.setData) source.setData(data);
|
|
2659
|
+
}
|
|
2660
|
+
/**
|
|
2661
|
+
* @deprecated Legacy refresh method - use PollingManager instead
|
|
2662
|
+
*/
|
|
2663
|
+
startRefreshInterval(layer) {
|
|
2664
|
+
if (typeof layer.source !== "object" || layer.source === null) {
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
const sourceObj = layer.source;
|
|
2668
|
+
if (sourceObj.type !== "geojson" || !sourceObj.url || !sourceObj.refreshInterval) {
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
const geojsonSource = layer.source;
|
|
2672
|
+
const interval = setInterval(async () => {
|
|
2673
|
+
const sourceId = `${layer.id}-source`;
|
|
2674
|
+
try {
|
|
2675
|
+
const cacheEnabled = geojsonSource.cache?.enabled ?? true;
|
|
2676
|
+
const cacheTTL = geojsonSource.cache?.ttl;
|
|
2677
|
+
const result = await this.dataFetcher.fetch(geojsonSource.url, {
|
|
2678
|
+
skipCache: !cacheEnabled,
|
|
2679
|
+
ttl: cacheTTL
|
|
2680
|
+
});
|
|
2681
|
+
const data = result.data;
|
|
2682
|
+
this.sourceData.set(sourceId, data);
|
|
2683
|
+
const source = this.map.getSource(sourceId);
|
|
2684
|
+
if (source?.setData) {
|
|
2685
|
+
source.setData(data);
|
|
2686
|
+
}
|
|
2687
|
+
this.callbacks.onDataLoaded?.(layer.id, data.features.length);
|
|
2688
|
+
} catch (error) {
|
|
2689
|
+
this.callbacks.onDataError?.(layer.id, error);
|
|
2690
|
+
}
|
|
2691
|
+
}, geojsonSource.refreshInterval);
|
|
2692
|
+
this.refreshIntervals.set(layer.id, interval);
|
|
2693
|
+
}
|
|
2694
|
+
stopRefreshInterval(layerId) {
|
|
2695
|
+
const interval = this.refreshIntervals.get(layerId);
|
|
2696
|
+
if (interval) {
|
|
2697
|
+
clearInterval(interval);
|
|
2698
|
+
this.refreshIntervals.delete(layerId);
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
clearAllIntervals() {
|
|
2702
|
+
for (const interval of this.refreshIntervals.values())
|
|
2703
|
+
clearInterval(interval);
|
|
2704
|
+
this.refreshIntervals.clear();
|
|
2705
|
+
}
|
|
2706
|
+
destroy() {
|
|
2707
|
+
this.pollingManager.destroy();
|
|
2708
|
+
this.streamManager.destroy();
|
|
2709
|
+
this.loadingManager.destroy();
|
|
2710
|
+
this.sourceData.clear();
|
|
2711
|
+
this.layerToSource.clear();
|
|
2712
|
+
this.clearAllIntervals();
|
|
2713
|
+
for (const controller of this.abortControllers.values()) controller.abort();
|
|
2714
|
+
this.abortControllers.clear();
|
|
2715
|
+
}
|
|
2716
|
+
};
|
|
2717
|
+
|
|
2718
|
+
// src/renderer/popup-builder.ts
|
|
2719
|
+
var PopupBuilder = class {
|
|
2720
|
+
/**
|
|
2721
|
+
* Build HTML string from popup content config and feature properties
|
|
2722
|
+
*/
|
|
2723
|
+
build(content, properties) {
|
|
2724
|
+
return content.map((item) => {
|
|
2725
|
+
const entries = Object.entries(item);
|
|
2726
|
+
if (entries.length === 0) return "";
|
|
2727
|
+
const entry = entries[0];
|
|
2728
|
+
if (!entry) return "";
|
|
2729
|
+
const [tag, items] = entry;
|
|
2730
|
+
if (!Array.isArray(items)) return "";
|
|
2731
|
+
const innerHTML = items.map((i) => this.buildItem(i, properties)).join("");
|
|
2732
|
+
return `<${tag}>${innerHTML}</${tag}>`;
|
|
2733
|
+
}).join("");
|
|
2734
|
+
}
|
|
2735
|
+
/**
|
|
2736
|
+
* Build a single content item
|
|
2737
|
+
*/
|
|
2738
|
+
buildItem(item, properties) {
|
|
2739
|
+
if (item.str) {
|
|
2740
|
+
return this.escapeHtml(item.str);
|
|
2741
|
+
}
|
|
2742
|
+
if (item.property) {
|
|
2743
|
+
const value = properties[item.property];
|
|
2744
|
+
if (value !== void 0 && value !== null) {
|
|
2745
|
+
if (item.format && typeof value === "number") {
|
|
2746
|
+
return this.formatNumber(value, item.format);
|
|
2747
|
+
}
|
|
2748
|
+
return this.escapeHtml(String(value));
|
|
2749
|
+
}
|
|
2750
|
+
return item.else ? this.escapeHtml(item.else) : "";
|
|
2751
|
+
}
|
|
2752
|
+
if (item.href) {
|
|
2753
|
+
const text = item.text || item.href;
|
|
2754
|
+
const target = item.target || "_blank";
|
|
2755
|
+
return `<a href="${this.escapeHtml(
|
|
2756
|
+
item.href
|
|
2757
|
+
)}" target="${target}">${this.escapeHtml(text)}</a>`;
|
|
2758
|
+
}
|
|
2759
|
+
if (item.src) {
|
|
2760
|
+
const alt = item.alt || "";
|
|
2761
|
+
return `<img src="${this.escapeHtml(item.src)}" alt="${this.escapeHtml(
|
|
2762
|
+
alt
|
|
2763
|
+
)}" />`;
|
|
2764
|
+
}
|
|
2765
|
+
return "";
|
|
2766
|
+
}
|
|
2767
|
+
/**
|
|
2768
|
+
* Format a number according to format string
|
|
2769
|
+
*/
|
|
2770
|
+
formatNumber(value, format) {
|
|
2771
|
+
const useThousands = format.includes(",");
|
|
2772
|
+
const decimalMatch = format.match(/\.(\d+)/);
|
|
2773
|
+
const decimals = decimalMatch && decimalMatch[1] ? parseInt(decimalMatch[1]) : 0;
|
|
2774
|
+
let result = value.toFixed(decimals);
|
|
2775
|
+
if (useThousands) {
|
|
2776
|
+
const parts = result.split(".");
|
|
2777
|
+
if (parts[0]) {
|
|
2778
|
+
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
2779
|
+
}
|
|
2780
|
+
result = parts.join(".");
|
|
2781
|
+
}
|
|
2782
|
+
return result;
|
|
2783
|
+
}
|
|
2784
|
+
/**
|
|
2785
|
+
* Escape HTML to prevent XSS
|
|
2786
|
+
*/
|
|
2787
|
+
escapeHtml(str) {
|
|
2788
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2789
|
+
}
|
|
2790
|
+
};
|
|
2791
|
+
|
|
2792
|
+
// src/renderer/event-handler.ts
|
|
2793
|
+
var EventHandler = class {
|
|
2794
|
+
map;
|
|
2795
|
+
callbacks;
|
|
2796
|
+
popupBuilder;
|
|
2797
|
+
activePopup;
|
|
2798
|
+
attachedLayers;
|
|
2799
|
+
boundHandlers;
|
|
2800
|
+
constructor(map, callbacks) {
|
|
2801
|
+
this.map = map;
|
|
2802
|
+
this.callbacks = callbacks || {};
|
|
2803
|
+
this.popupBuilder = new PopupBuilder();
|
|
2804
|
+
this.activePopup = null;
|
|
2805
|
+
this.attachedLayers = /* @__PURE__ */ new Set();
|
|
2806
|
+
this.boundHandlers = /* @__PURE__ */ new Map();
|
|
2807
|
+
}
|
|
2808
|
+
/**
|
|
2809
|
+
* Attach events for a layer based on its interactive config
|
|
2810
|
+
*/
|
|
2811
|
+
attachEvents(layer) {
|
|
2812
|
+
if (!layer.interactive) return;
|
|
2813
|
+
const interactive = layer.interactive;
|
|
2814
|
+
const { hover, click } = interactive;
|
|
2815
|
+
const handlers = {};
|
|
2816
|
+
if (hover) {
|
|
2817
|
+
handlers.mouseenter = (e) => {
|
|
2818
|
+
if (hover.cursor) {
|
|
2819
|
+
this.map.getCanvas().style.cursor = hover.cursor;
|
|
2820
|
+
}
|
|
2821
|
+
if (e.features?.[0]) {
|
|
2822
|
+
this.callbacks.onHover?.(layer.id, e.features[0], e.lngLat);
|
|
2823
|
+
}
|
|
2824
|
+
};
|
|
2825
|
+
handlers.mouseleave = () => {
|
|
2826
|
+
this.map.getCanvas().style.cursor = "";
|
|
2827
|
+
};
|
|
2828
|
+
this.map.on("mouseenter", layer.id, handlers.mouseenter);
|
|
2829
|
+
this.map.on("mouseleave", layer.id, handlers.mouseleave);
|
|
2830
|
+
}
|
|
2831
|
+
if (click) {
|
|
2832
|
+
handlers.click = (e) => {
|
|
2833
|
+
const feature = e.features?.[0];
|
|
2834
|
+
if (!feature) return;
|
|
2835
|
+
if (click.popup) {
|
|
2836
|
+
this.showPopup(click.popup, feature, e.lngLat);
|
|
2837
|
+
}
|
|
2838
|
+
this.callbacks.onClick?.(layer.id, feature, e.lngLat);
|
|
2839
|
+
};
|
|
2840
|
+
this.map.on("click", layer.id, handlers.click);
|
|
2841
|
+
}
|
|
2842
|
+
this.boundHandlers.set(layer.id, handlers);
|
|
2843
|
+
this.attachedLayers.add(layer.id);
|
|
2844
|
+
}
|
|
2845
|
+
/**
|
|
2846
|
+
* Show a popup with content
|
|
2847
|
+
*/
|
|
2848
|
+
showPopup(content, feature, lngLat) {
|
|
2849
|
+
this.activePopup?.remove();
|
|
2850
|
+
const html = this.popupBuilder.build(content, feature.properties);
|
|
2851
|
+
this.activePopup = new maplibregl2.Popup().setLngLat(lngLat).setHTML(html).addTo(this.map);
|
|
2852
|
+
}
|
|
2853
|
+
/**
|
|
2854
|
+
* Detach events for a layer
|
|
2855
|
+
*/
|
|
2856
|
+
detachEvents(layerId) {
|
|
2857
|
+
const handlers = this.boundHandlers.get(layerId);
|
|
2858
|
+
if (!handlers) return;
|
|
2859
|
+
if (handlers.click) {
|
|
2860
|
+
this.map.off("click", layerId, handlers.click);
|
|
2861
|
+
}
|
|
2862
|
+
if (handlers.mouseenter) {
|
|
2863
|
+
this.map.off("mouseenter", layerId, handlers.mouseenter);
|
|
2864
|
+
}
|
|
2865
|
+
if (handlers.mouseleave) {
|
|
2866
|
+
this.map.off("mouseleave", layerId, handlers.mouseleave);
|
|
2867
|
+
}
|
|
2868
|
+
this.boundHandlers.delete(layerId);
|
|
2869
|
+
this.attachedLayers.delete(layerId);
|
|
2870
|
+
}
|
|
2871
|
+
/**
|
|
2872
|
+
* Clean up all event handlers
|
|
2873
|
+
*/
|
|
2874
|
+
destroy() {
|
|
2875
|
+
for (const layerId of this.attachedLayers) {
|
|
2876
|
+
this.detachEvents(layerId);
|
|
2877
|
+
}
|
|
2878
|
+
this.activePopup?.remove();
|
|
2879
|
+
this.activePopup = null;
|
|
2880
|
+
}
|
|
2881
|
+
};
|
|
2882
|
+
|
|
2883
|
+
// src/renderer/legend-builder.ts
|
|
2884
|
+
var LegendBuilder = class {
|
|
2885
|
+
/**
|
|
2886
|
+
* Build legend in container from layers
|
|
2887
|
+
*/
|
|
2888
|
+
build(container, layers, config) {
|
|
2889
|
+
const el = typeof container === "string" ? document.getElementById(container) : container;
|
|
2890
|
+
if (!el) return;
|
|
2891
|
+
const items = config?.items || this.extractItems(layers);
|
|
2892
|
+
let html = '<div class="maplibre-legend">';
|
|
2893
|
+
if (config?.title) {
|
|
2894
|
+
html += `<div class="legend-title">${this.escapeHtml(config.title)}</div>`;
|
|
2895
|
+
}
|
|
2896
|
+
html += '<div class="legend-items">';
|
|
2897
|
+
for (const item of items) {
|
|
2898
|
+
html += this.renderItem(item);
|
|
2899
|
+
}
|
|
2900
|
+
html += "</div></div>";
|
|
2901
|
+
el.innerHTML = html;
|
|
2902
|
+
}
|
|
2903
|
+
/**
|
|
2904
|
+
* Render a single legend item
|
|
2905
|
+
*/
|
|
2906
|
+
renderItem(item) {
|
|
2907
|
+
const shape = item.shape || "square";
|
|
2908
|
+
let symbol = "";
|
|
2909
|
+
switch (shape) {
|
|
2910
|
+
case "circle":
|
|
2911
|
+
symbol = `<span class="legend-symbol circle" style="background:${this.escapeHtml(item.color)}"></span>`;
|
|
2912
|
+
break;
|
|
2913
|
+
case "line":
|
|
2914
|
+
symbol = `<span class="legend-symbol line" style="background:${this.escapeHtml(item.color)}"></span>`;
|
|
2915
|
+
break;
|
|
2916
|
+
case "icon":
|
|
2917
|
+
if (item.icon) {
|
|
2918
|
+
symbol = `<span class="legend-symbol icon">${this.escapeHtml(item.icon)}</span>`;
|
|
2919
|
+
} else {
|
|
2920
|
+
symbol = `<span class="legend-symbol square" style="background:${this.escapeHtml(item.color)}"></span>`;
|
|
2921
|
+
}
|
|
2922
|
+
break;
|
|
2923
|
+
default:
|
|
2924
|
+
symbol = `<span class="legend-symbol square" style="background:${this.escapeHtml(item.color)}"></span>`;
|
|
2925
|
+
}
|
|
2926
|
+
return `<div class="legend-item">${symbol}<span class="legend-label">${this.escapeHtml(item.label)}</span></div>`;
|
|
2927
|
+
}
|
|
2928
|
+
/**
|
|
2929
|
+
* Extract legend items from layers
|
|
2930
|
+
*/
|
|
2931
|
+
extractItems(layers) {
|
|
2932
|
+
return layers.filter((l) => l.legend && typeof l.legend === "object").map((l) => l.legend);
|
|
2933
|
+
}
|
|
2934
|
+
/**
|
|
2935
|
+
* Escape HTML to prevent XSS
|
|
2936
|
+
*/
|
|
2937
|
+
escapeHtml(str) {
|
|
2938
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2939
|
+
}
|
|
2940
|
+
};
|
|
2941
|
+
var ControlsManager = class {
|
|
2942
|
+
map;
|
|
2943
|
+
addedControls;
|
|
2944
|
+
constructor(map) {
|
|
2945
|
+
this.map = map;
|
|
2946
|
+
this.addedControls = [];
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* Add controls to the map based on configuration
|
|
2950
|
+
*/
|
|
2951
|
+
addControls(config) {
|
|
2952
|
+
if (!config) return;
|
|
2953
|
+
if (config.navigation) {
|
|
2954
|
+
const options = typeof config.navigation === "object" ? config.navigation : {};
|
|
2955
|
+
const position = options.position || "top-right";
|
|
2956
|
+
const control = new maplibregl2.NavigationControl();
|
|
2957
|
+
this.map.addControl(control, position);
|
|
2958
|
+
this.addedControls.push(control);
|
|
2959
|
+
}
|
|
2960
|
+
if (config.geolocate) {
|
|
2961
|
+
const options = typeof config.geolocate === "object" ? config.geolocate : {};
|
|
2962
|
+
const position = options.position || "top-right";
|
|
2963
|
+
const control = new maplibregl2.GeolocateControl({
|
|
2964
|
+
positionOptions: { enableHighAccuracy: true },
|
|
2965
|
+
trackUserLocation: true
|
|
2966
|
+
});
|
|
2967
|
+
this.map.addControl(control, position);
|
|
2968
|
+
this.addedControls.push(control);
|
|
2969
|
+
}
|
|
2970
|
+
if (config.scale) {
|
|
2971
|
+
const options = typeof config.scale === "object" ? config.scale : {};
|
|
2972
|
+
const position = options.position || "bottom-left";
|
|
2973
|
+
const control = new maplibregl2.ScaleControl();
|
|
2974
|
+
this.map.addControl(control, position);
|
|
2975
|
+
this.addedControls.push(control);
|
|
2976
|
+
}
|
|
2977
|
+
if (config.fullscreen) {
|
|
2978
|
+
const options = typeof config.fullscreen === "object" ? config.fullscreen : {};
|
|
2979
|
+
const position = options.position || "top-right";
|
|
2980
|
+
const control = new maplibregl2.FullscreenControl();
|
|
2981
|
+
this.map.addControl(control, position);
|
|
2982
|
+
this.addedControls.push(control);
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
/**
|
|
2986
|
+
* Remove all controls from the map
|
|
2987
|
+
*/
|
|
2988
|
+
removeAllControls() {
|
|
2989
|
+
for (const control of this.addedControls) {
|
|
2990
|
+
this.map.removeControl(control);
|
|
2991
|
+
}
|
|
2992
|
+
this.addedControls = [];
|
|
2993
|
+
}
|
|
2994
|
+
};
|
|
2995
|
+
|
|
2996
|
+
// src/renderer/map-renderer.ts
|
|
2997
|
+
var MapRenderer = class {
|
|
2998
|
+
map;
|
|
2999
|
+
layerManager;
|
|
3000
|
+
eventHandler;
|
|
3001
|
+
legendBuilder;
|
|
3002
|
+
controlsManager;
|
|
3003
|
+
eventListeners;
|
|
3004
|
+
isLoaded;
|
|
3005
|
+
constructor(container, config, layers = [], options = {}) {
|
|
3006
|
+
this.eventListeners = /* @__PURE__ */ new Map();
|
|
3007
|
+
this.isLoaded = false;
|
|
3008
|
+
this.map = new maplibregl2.Map({
|
|
3009
|
+
...config,
|
|
3010
|
+
container: typeof container === "string" ? container : container,
|
|
3011
|
+
style: config.mapStyle,
|
|
3012
|
+
center: config.center,
|
|
3013
|
+
zoom: config.zoom,
|
|
3014
|
+
pitch: config.pitch ?? 0,
|
|
3015
|
+
bearing: config.bearing ?? 0,
|
|
3016
|
+
interactive: config.interactive ?? true
|
|
3017
|
+
});
|
|
3018
|
+
const layerCallbacks = {
|
|
3019
|
+
onDataLoading: (layerId) => this.emit("layer:data-loading", { layerId }),
|
|
3020
|
+
onDataLoaded: (layerId, featureCount) => this.emit("layer:data-loaded", { layerId, featureCount }),
|
|
3021
|
+
onDataError: (layerId, error) => this.emit("layer:data-error", { layerId, error })
|
|
3022
|
+
};
|
|
3023
|
+
const eventCallbacks = {
|
|
3024
|
+
onClick: (layerId, feature, lngLat) => this.emit("layer:click", { layerId, feature, lngLat }),
|
|
3025
|
+
onHover: (layerId, feature, lngLat) => this.emit("layer:hover", { layerId, feature, lngLat })
|
|
3026
|
+
};
|
|
3027
|
+
this.layerManager = new LayerManager(this.map, layerCallbacks);
|
|
3028
|
+
this.eventHandler = new EventHandler(this.map, eventCallbacks);
|
|
3029
|
+
this.legendBuilder = new LegendBuilder();
|
|
3030
|
+
this.controlsManager = new ControlsManager(this.map);
|
|
3031
|
+
this.map.on("load", () => {
|
|
3032
|
+
this.isLoaded = true;
|
|
3033
|
+
Promise.all(layers.map((layer) => this.addLayer(layer))).then(() => {
|
|
3034
|
+
this.emit("load", void 0);
|
|
3035
|
+
options.onLoad?.();
|
|
3036
|
+
}).catch((error) => {
|
|
3037
|
+
options.onError?.(error);
|
|
3038
|
+
});
|
|
3039
|
+
});
|
|
3040
|
+
this.map.on("error", (e) => {
|
|
3041
|
+
options.onError?.(e.error);
|
|
3042
|
+
});
|
|
3043
|
+
}
|
|
3044
|
+
/**
|
|
3045
|
+
* Get the underlying MapLibre map instance
|
|
3046
|
+
*/
|
|
3047
|
+
getMap() {
|
|
3048
|
+
return this.map;
|
|
3049
|
+
}
|
|
3050
|
+
/**
|
|
3051
|
+
* Check if map is loaded
|
|
3052
|
+
*/
|
|
3053
|
+
isMapLoaded() {
|
|
3054
|
+
return this.isLoaded;
|
|
3055
|
+
}
|
|
3056
|
+
/**
|
|
3057
|
+
* Add a layer to the map
|
|
3058
|
+
*/
|
|
3059
|
+
async addLayer(layer) {
|
|
3060
|
+
await this.layerManager.addLayer(layer);
|
|
3061
|
+
this.eventHandler.attachEvents(layer);
|
|
3062
|
+
this.emit("layer:added", { layerId: layer.id });
|
|
3063
|
+
}
|
|
3064
|
+
/**
|
|
3065
|
+
* Remove a layer from the map
|
|
3066
|
+
*/
|
|
3067
|
+
removeLayer(layerId) {
|
|
3068
|
+
this.eventHandler.detachEvents(layerId);
|
|
3069
|
+
this.layerManager.removeLayer(layerId);
|
|
3070
|
+
this.emit("layer:removed", { layerId });
|
|
3071
|
+
}
|
|
3072
|
+
/**
|
|
3073
|
+
* Set layer visibility
|
|
3074
|
+
*/
|
|
3075
|
+
setLayerVisibility(layerId, visible) {
|
|
3076
|
+
this.layerManager.setVisibility(layerId, visible);
|
|
3077
|
+
}
|
|
3078
|
+
/**
|
|
3079
|
+
* Update layer data
|
|
3080
|
+
*/
|
|
3081
|
+
updateLayerData(layerId, data) {
|
|
3082
|
+
this.layerManager.updateData(layerId, data);
|
|
3083
|
+
}
|
|
3084
|
+
/**
|
|
3085
|
+
* Add controls to the map
|
|
3086
|
+
*/
|
|
3087
|
+
addControls(config) {
|
|
3088
|
+
this.controlsManager.addControls(config);
|
|
3089
|
+
}
|
|
3090
|
+
/**
|
|
3091
|
+
* Build legend in container
|
|
3092
|
+
*/
|
|
3093
|
+
buildLegend(container, layers, config) {
|
|
3094
|
+
this.legendBuilder.build(container, layers, config);
|
|
3095
|
+
}
|
|
3096
|
+
/**
|
|
3097
|
+
* Get the legend builder instance
|
|
3098
|
+
*/
|
|
3099
|
+
getLegendBuilder() {
|
|
3100
|
+
return this.legendBuilder;
|
|
3101
|
+
}
|
|
3102
|
+
/**
|
|
3103
|
+
* Register an event listener
|
|
3104
|
+
*/
|
|
3105
|
+
on(event, callback) {
|
|
3106
|
+
if (!this.eventListeners.has(event)) {
|
|
3107
|
+
this.eventListeners.set(event, /* @__PURE__ */ new Set());
|
|
3108
|
+
}
|
|
3109
|
+
this.eventListeners.get(event).add(callback);
|
|
3110
|
+
}
|
|
3111
|
+
/**
|
|
3112
|
+
* Unregister an event listener
|
|
3113
|
+
*/
|
|
3114
|
+
off(event, callback) {
|
|
3115
|
+
const listeners = this.eventListeners.get(event);
|
|
3116
|
+
if (listeners) {
|
|
3117
|
+
listeners.delete(callback);
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
/**
|
|
3121
|
+
* Emit an event
|
|
3122
|
+
*/
|
|
3123
|
+
emit(event, data) {
|
|
3124
|
+
const listeners = this.eventListeners.get(event);
|
|
3125
|
+
if (listeners) {
|
|
3126
|
+
for (const callback of listeners) {
|
|
3127
|
+
callback(data);
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3131
|
+
/**
|
|
3132
|
+
* Destroy the map and clean up resources
|
|
3133
|
+
*/
|
|
3134
|
+
destroy() {
|
|
3135
|
+
this.eventHandler.destroy();
|
|
3136
|
+
this.layerManager.destroy();
|
|
3137
|
+
this.controlsManager.removeAllControls();
|
|
3138
|
+
this.eventListeners.clear();
|
|
3139
|
+
this.map.remove();
|
|
3140
|
+
}
|
|
3141
|
+
};
|
|
3142
|
+
var ValidTagNames = [
|
|
3143
|
+
"h1",
|
|
3144
|
+
"h2",
|
|
3145
|
+
"h3",
|
|
3146
|
+
"h4",
|
|
3147
|
+
"h5",
|
|
3148
|
+
"h6",
|
|
3149
|
+
"p",
|
|
3150
|
+
"span",
|
|
3151
|
+
"div",
|
|
3152
|
+
"a",
|
|
3153
|
+
"strong",
|
|
3154
|
+
"em",
|
|
3155
|
+
"code",
|
|
3156
|
+
"pre",
|
|
3157
|
+
"img",
|
|
3158
|
+
"iframe",
|
|
3159
|
+
"ul",
|
|
3160
|
+
"ol",
|
|
3161
|
+
"li",
|
|
3162
|
+
"blockquote",
|
|
3163
|
+
"hr",
|
|
3164
|
+
"br"
|
|
3165
|
+
];
|
|
3166
|
+
var ContentElementSchema = z.object({
|
|
3167
|
+
// Content
|
|
3168
|
+
str: z.string().optional().describe("Static text string"),
|
|
3169
|
+
property: z.string().optional().describe("Dynamic property from context"),
|
|
3170
|
+
else: z.string().optional().describe("Fallback value if property missing"),
|
|
3171
|
+
// Styling
|
|
3172
|
+
classList: z.union([z.string(), z.array(z.string())]).optional().describe("CSS class names (space-separated string or array)"),
|
|
3173
|
+
id: z.string().optional().describe("Element ID attribute"),
|
|
3174
|
+
style: z.string().optional().describe("Inline CSS styles"),
|
|
3175
|
+
// Links
|
|
3176
|
+
href: z.string().url().optional().describe("Link URL"),
|
|
3177
|
+
target: z.string().optional().describe("Link target (_blank, _self, _parent, _top)"),
|
|
3178
|
+
// Media
|
|
3179
|
+
src: z.string().url().optional().describe("Source URL for img or iframe"),
|
|
3180
|
+
alt: z.string().optional().describe("Alternative text for images"),
|
|
3181
|
+
width: z.union([z.string(), z.number()]).optional().describe("Width (pixels or %)"),
|
|
3182
|
+
height: z.union([z.string(), z.number()]).optional().describe("Height (pixels or %)")
|
|
3183
|
+
}).passthrough().describe("Content element with styling and properties");
|
|
3184
|
+
var ContentItemSchema = z.record(z.enum(ValidTagNames), z.array(ContentElementSchema)).describe("Content item mapping tag to elements");
|
|
3185
|
+
var ContentBlockSchema = z.object({
|
|
3186
|
+
type: z.literal("content").describe("Block type"),
|
|
3187
|
+
id: z.string().optional().describe("Unique block identifier"),
|
|
3188
|
+
className: z.string().optional().describe("CSS class name for the block container"),
|
|
3189
|
+
style: z.string().optional().describe("Inline CSS styles for the block container"),
|
|
3190
|
+
content: z.array(ContentItemSchema).describe("Array of content items to render")
|
|
3191
|
+
}).describe("Content block for rich text and media");
|
|
3192
|
+
var LongitudeSchema = z.number().min(-180, "Longitude must be >= -180").max(180, "Longitude must be <= 180").describe("Longitude in degrees (-180 to 180)");
|
|
3193
|
+
var LatitudeSchema = z.number().min(-90, "Latitude must be >= -90").max(90, "Latitude must be <= 90").describe("Latitude in degrees (-90 to 90)");
|
|
3194
|
+
var LngLatSchema = z.tuple([LongitudeSchema, LatitudeSchema]).describe("Geographic coordinates as [longitude, latitude]");
|
|
3195
|
+
var LngLatBoundsSchema = z.tuple([
|
|
3196
|
+
LongitudeSchema,
|
|
3197
|
+
// west
|
|
3198
|
+
LatitudeSchema,
|
|
3199
|
+
// south
|
|
3200
|
+
LongitudeSchema,
|
|
3201
|
+
// east
|
|
3202
|
+
LatitudeSchema
|
|
3203
|
+
// north
|
|
3204
|
+
]).describe("Bounding box as [west, south, east, north]");
|
|
3205
|
+
var ColorSchema = z.string().refine(
|
|
3206
|
+
(val) => {
|
|
3207
|
+
if (val.startsWith("#")) {
|
|
3208
|
+
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(val);
|
|
3209
|
+
}
|
|
3210
|
+
if (val.startsWith("rgb")) {
|
|
3211
|
+
return /^rgba?\s*\([^)]+\)$/.test(val);
|
|
3212
|
+
}
|
|
3213
|
+
if (val.startsWith("hsl")) {
|
|
3214
|
+
return /^hsla?\s*\([^)]+\)$/.test(val);
|
|
3215
|
+
}
|
|
3216
|
+
return true;
|
|
3217
|
+
},
|
|
3218
|
+
{
|
|
3219
|
+
message: "Invalid color format. Use hex (#rgb, #rrggbb), rgb(), rgba(), hsl(), hsla(), or named colors."
|
|
3220
|
+
}
|
|
3221
|
+
).describe("CSS color value");
|
|
3222
|
+
var ExpressionSchema = z.array(z.any()).refine((val) => val.length > 0 && typeof val[0] === "string", {
|
|
3223
|
+
message: 'Expression must be an array starting with a string operator (e.g., ["get", "property"])'
|
|
3224
|
+
}).describe("MapLibre expression for data-driven styling");
|
|
3225
|
+
var NumberOrExpressionSchema = z.union([z.number(), ExpressionSchema]).describe("Number value or MapLibre expression");
|
|
3226
|
+
var ColorOrExpressionSchema = z.union([ColorSchema, ExpressionSchema]).describe("Color value or MapLibre expression");
|
|
3227
|
+
var ZoomLevelSchema = z.number().min(0, "Zoom level must be >= 0").max(24, "Zoom level must be <= 24").describe("Map zoom level (0-24)");
|
|
3228
|
+
var StreamConfigSchema = z.object({
|
|
3229
|
+
type: z.enum(["websocket", "sse"]).describe("Streaming connection type"),
|
|
3230
|
+
url: z.string().url().optional().describe("WebSocket or SSE endpoint URL"),
|
|
3231
|
+
reconnect: z.boolean().default(true).describe("Automatically reconnect on disconnect"),
|
|
3232
|
+
reconnectMaxAttempts: z.number().min(0).default(10).describe("Maximum number of reconnection attempts"),
|
|
3233
|
+
reconnectDelay: z.number().min(100).default(1e3).describe("Initial delay in milliseconds before reconnecting"),
|
|
3234
|
+
reconnectMaxDelay: z.number().min(1e3).default(3e4).describe("Maximum delay in milliseconds for exponential backoff"),
|
|
3235
|
+
eventTypes: z.array(z.string()).optional().describe("Event types to listen for (SSE only)"),
|
|
3236
|
+
protocols: z.union([z.string(), z.array(z.string())]).optional().describe("WebSocket sub-protocols (WebSocket only)")
|
|
3237
|
+
});
|
|
3238
|
+
var LoadingConfigSchema = z.object({
|
|
3239
|
+
enabled: z.boolean().default(false).describe("Enable loading UI overlays"),
|
|
3240
|
+
message: z.string().optional().describe("Custom loading message to display"),
|
|
3241
|
+
showErrorOverlay: z.boolean().default(true).describe("Show error overlay on fetch failure")
|
|
3242
|
+
});
|
|
3243
|
+
var CacheConfigSchema = z.object({
|
|
3244
|
+
enabled: z.boolean().default(true).describe("Enable HTTP caching"),
|
|
3245
|
+
ttl: z.number().positive().optional().describe("Cache TTL in milliseconds (overrides default)")
|
|
3246
|
+
});
|
|
3247
|
+
var RefreshConfigSchema = z.object({
|
|
3248
|
+
refreshInterval: z.number().min(1e3).optional().describe("Polling interval in milliseconds (minimum 1000ms)"),
|
|
3249
|
+
updateStrategy: z.enum(["replace", "merge", "append-window"]).default("replace").describe("How to merge incoming data with existing data"),
|
|
3250
|
+
updateKey: z.string().optional().describe("Property key for merge strategy (required for merge)"),
|
|
3251
|
+
windowSize: z.number().positive().optional().describe("Maximum features to keep (append-window)"),
|
|
3252
|
+
windowDuration: z.number().positive().optional().describe("Maximum age in milliseconds (append-window)"),
|
|
3253
|
+
timestampField: z.string().optional().describe("Property field containing timestamp (append-window)")
|
|
3254
|
+
}).refine((data) => !(data.updateStrategy === "merge" && !data.updateKey), {
|
|
3255
|
+
message: "updateKey is required when updateStrategy is 'merge'"
|
|
3256
|
+
});
|
|
3257
|
+
var GeoJSONSourceSchema = z.object({
|
|
3258
|
+
type: z.literal("geojson").describe("Source type"),
|
|
3259
|
+
url: z.string().url().optional().describe("URL to fetch GeoJSON data"),
|
|
3260
|
+
data: z.any().optional().describe("Inline GeoJSON object"),
|
|
3261
|
+
prefetchedData: z.any().optional().describe("Pre-fetched data from build time"),
|
|
3262
|
+
fetchStrategy: z.enum(["runtime", "build", "hybrid"]).default("runtime").describe("When to fetch data: runtime (default), build, or hybrid"),
|
|
3263
|
+
stream: StreamConfigSchema.optional().describe(
|
|
3264
|
+
"WebSocket/SSE streaming configuration"
|
|
3265
|
+
),
|
|
3266
|
+
refresh: RefreshConfigSchema.optional().describe(
|
|
3267
|
+
"Polling refresh configuration"
|
|
3268
|
+
),
|
|
3269
|
+
// Legacy support for direct refresh properties
|
|
3270
|
+
refreshInterval: z.number().min(1e3).optional().describe("Polling interval in milliseconds (legacy, use refresh.refreshInterval)"),
|
|
3271
|
+
updateStrategy: z.enum(["replace", "merge", "append-window"]).optional().describe("Update strategy (legacy, use refresh.updateStrategy)"),
|
|
3272
|
+
updateKey: z.string().optional().describe("Update key (legacy, use refresh.updateKey)"),
|
|
3273
|
+
loading: LoadingConfigSchema.optional().describe(
|
|
3274
|
+
"Loading UI configuration"
|
|
3275
|
+
),
|
|
3276
|
+
cache: CacheConfigSchema.optional().describe("Cache configuration"),
|
|
3277
|
+
// MapLibre clustering options
|
|
3278
|
+
cluster: z.boolean().optional().describe("Enable point clustering"),
|
|
3279
|
+
clusterRadius: z.number().int().min(0).default(50).describe("Cluster radius in pixels"),
|
|
3280
|
+
clusterMaxZoom: z.number().min(0).max(24).optional().describe("Maximum zoom level to cluster points"),
|
|
3281
|
+
clusterMinPoints: z.number().int().min(2).optional().describe("Minimum points to form a cluster"),
|
|
3282
|
+
clusterProperties: z.record(z.any()).optional().describe("Aggregate cluster properties"),
|
|
3283
|
+
// Additional MapLibre options (passthrough)
|
|
3284
|
+
tolerance: z.number().optional(),
|
|
3285
|
+
buffer: z.number().optional(),
|
|
3286
|
+
lineMetrics: z.boolean().optional(),
|
|
3287
|
+
generateId: z.boolean().optional(),
|
|
3288
|
+
promoteId: z.union([z.string(), z.record(z.string())]).optional(),
|
|
3289
|
+
attribution: z.string().optional()
|
|
3290
|
+
}).passthrough().refine((data) => data.url || data.data || data.prefetchedData, {
|
|
3291
|
+
message: 'GeoJSON source requires at least one of: url, data, or prefetchedData. Use "url" to fetch from an endpoint, "data" for inline GeoJSON, or "prefetchedData" for build-time fetched data.'
|
|
3292
|
+
});
|
|
3293
|
+
var VectorSourceSchema = z.object({
|
|
3294
|
+
type: z.literal("vector").describe("Source type"),
|
|
3295
|
+
url: z.string().url().optional().describe("TileJSON URL"),
|
|
3296
|
+
tiles: z.array(z.string().url()).optional().describe("Tile URL template array"),
|
|
3297
|
+
minzoom: z.number().min(0).max(24).optional().describe("Minimum zoom level"),
|
|
3298
|
+
maxzoom: z.number().min(0).max(24).optional().describe("Maximum zoom level"),
|
|
3299
|
+
bounds: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional().describe("Bounding box [west, south, east, north]"),
|
|
3300
|
+
scheme: z.enum(["xyz", "tms"]).optional().describe("Tile coordinate scheme"),
|
|
3301
|
+
attribution: z.string().optional().describe("Attribution text"),
|
|
3302
|
+
promoteId: z.union([z.string(), z.record(z.string())]).optional(),
|
|
3303
|
+
volatile: z.boolean().optional()
|
|
3304
|
+
}).passthrough().refine((data) => data.url || data.tiles, {
|
|
3305
|
+
message: 'Vector source requires either "url" (TileJSON) or "tiles" (tile URL array). Provide at least one of these properties.'
|
|
3306
|
+
});
|
|
3307
|
+
var RasterSourceSchema = z.object({
|
|
3308
|
+
type: z.literal("raster").describe("Source type"),
|
|
3309
|
+
url: z.string().url().optional().describe("TileJSON URL"),
|
|
3310
|
+
tiles: z.array(z.string().url()).optional().describe("Tile URL template array"),
|
|
3311
|
+
tileSize: z.number().int().min(1).default(512).describe("Tile size in pixels"),
|
|
3312
|
+
minzoom: z.number().min(0).max(24).optional().describe("Minimum zoom level"),
|
|
3313
|
+
maxzoom: z.number().min(0).max(24).optional().describe("Maximum zoom level"),
|
|
3314
|
+
bounds: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional().describe("Bounding box [west, south, east, north]"),
|
|
3315
|
+
scheme: z.enum(["xyz", "tms"]).optional().describe("Tile coordinate scheme"),
|
|
3316
|
+
attribution: z.string().optional().describe("Attribution text"),
|
|
3317
|
+
volatile: z.boolean().optional()
|
|
3318
|
+
}).passthrough().refine((data) => data.url || data.tiles, {
|
|
3319
|
+
message: 'Raster source requires either "url" (TileJSON) or "tiles" (tile URL array). Provide at least one of these properties.'
|
|
3320
|
+
});
|
|
3321
|
+
var ImageSourceSchema = z.object({
|
|
3322
|
+
type: z.literal("image").describe("Source type"),
|
|
3323
|
+
url: z.string().url().describe("Image URL"),
|
|
3324
|
+
coordinates: z.tuple([LngLatSchema, LngLatSchema, LngLatSchema, LngLatSchema]).describe(
|
|
3325
|
+
"Four corner coordinates [topLeft, topRight, bottomRight, bottomLeft]"
|
|
3326
|
+
)
|
|
3327
|
+
}).passthrough();
|
|
3328
|
+
var VideoSourceSchema = z.object({
|
|
3329
|
+
type: z.literal("video").describe("Source type"),
|
|
3330
|
+
urls: z.array(z.string().url()).min(1).describe("Array of video URLs for browser compatibility"),
|
|
3331
|
+
coordinates: z.tuple([LngLatSchema, LngLatSchema, LngLatSchema, LngLatSchema]).describe(
|
|
3332
|
+
"Four corner coordinates [topLeft, topRight, bottomRight, bottomLeft]"
|
|
3333
|
+
)
|
|
3334
|
+
}).passthrough();
|
|
3335
|
+
var LayerSourceSchema = z.union([
|
|
3336
|
+
GeoJSONSourceSchema,
|
|
3337
|
+
VectorSourceSchema,
|
|
3338
|
+
RasterSourceSchema,
|
|
3339
|
+
ImageSourceSchema,
|
|
3340
|
+
VideoSourceSchema
|
|
3341
|
+
]);
|
|
3342
|
+
|
|
3343
|
+
// src/schemas/layer.schema.ts
|
|
3344
|
+
var PopupContentItemSchema = z.object({
|
|
3345
|
+
str: z.string().optional().describe("Static text string"),
|
|
3346
|
+
property: z.string().optional().describe("Feature property name"),
|
|
3347
|
+
else: z.string().optional().describe("Fallback value if property missing"),
|
|
3348
|
+
format: z.string().optional().describe('Number format string (e.g., ",.0f")'),
|
|
3349
|
+
href: z.string().url().optional().describe("Link URL"),
|
|
3350
|
+
text: z.string().optional().describe("Link text"),
|
|
3351
|
+
src: z.string().url().optional().describe("Image/iframe source"),
|
|
3352
|
+
alt: z.string().optional().describe("Image alt text")
|
|
3353
|
+
}).passthrough().describe("Popup content item with static or dynamic values");
|
|
3354
|
+
var PopupContentSchema = z.array(z.record(z.array(PopupContentItemSchema))).describe("Popup content structure as array of HTML elements");
|
|
3355
|
+
var InteractiveConfigSchema = z.object({
|
|
3356
|
+
hover: z.object({
|
|
3357
|
+
cursor: z.string().optional().describe('CSS cursor style (e.g., "pointer")'),
|
|
3358
|
+
highlight: z.boolean().optional().describe("Highlight feature on hover")
|
|
3359
|
+
}).optional().describe("Hover behavior"),
|
|
3360
|
+
click: z.object({
|
|
3361
|
+
popup: PopupContentSchema.optional().describe(
|
|
3362
|
+
"Popup content to display"
|
|
3363
|
+
),
|
|
3364
|
+
action: z.string().optional().describe("Custom action name to trigger"),
|
|
3365
|
+
flyTo: z.object({
|
|
3366
|
+
center: z.tuple([z.number(), z.number()]).optional(),
|
|
3367
|
+
zoom: ZoomLevelSchema.optional(),
|
|
3368
|
+
duration: z.number().optional()
|
|
3369
|
+
}).optional().describe("Fly to location on click")
|
|
3370
|
+
}).optional().describe("Click behavior"),
|
|
3371
|
+
mouseenter: z.object({
|
|
3372
|
+
action: z.string().optional().describe("Custom action on mouse enter")
|
|
3373
|
+
}).optional(),
|
|
3374
|
+
mouseleave: z.object({
|
|
3375
|
+
action: z.string().optional().describe("Custom action on mouse leave")
|
|
3376
|
+
}).optional()
|
|
3377
|
+
}).optional().describe("Interactive event configuration");
|
|
3378
|
+
var LegendItemSchema = z.object({
|
|
3379
|
+
color: z.string().describe("CSS color value"),
|
|
3380
|
+
label: z.string().describe("Legend label text"),
|
|
3381
|
+
shape: z.enum(["circle", "square", "line", "icon"]).default("square").describe("Symbol shape"),
|
|
3382
|
+
icon: z.string().optional().describe("Icon name or URL (for shape: icon)")
|
|
3383
|
+
}).describe("Legend item configuration");
|
|
3384
|
+
var BaseLayerPropertiesSchema = z.object({
|
|
3385
|
+
id: z.string().describe("Unique layer identifier"),
|
|
3386
|
+
label: z.string().optional().describe("Human-readable layer label"),
|
|
3387
|
+
source: z.union([LayerSourceSchema, z.string()]).describe("Layer source (inline definition or source ID reference)"),
|
|
3388
|
+
"source-layer": z.string().optional().describe("Source layer name (for vector sources)"),
|
|
3389
|
+
minzoom: ZoomLevelSchema.optional().describe(
|
|
3390
|
+
"Minimum zoom level to show layer"
|
|
3391
|
+
),
|
|
3392
|
+
maxzoom: ZoomLevelSchema.optional().describe(
|
|
3393
|
+
"Maximum zoom level to show layer"
|
|
3394
|
+
),
|
|
3395
|
+
filter: ExpressionSchema.optional().describe("MapLibre filter expression"),
|
|
3396
|
+
visible: z.boolean().default(true).describe("Initial visibility state"),
|
|
3397
|
+
toggleable: z.boolean().default(true).describe("Allow users to toggle visibility"),
|
|
3398
|
+
before: z.string().optional().describe("Layer ID to insert this layer before"),
|
|
3399
|
+
interactive: InteractiveConfigSchema.describe(
|
|
3400
|
+
"Interactive event configuration"
|
|
3401
|
+
),
|
|
3402
|
+
legend: LegendItemSchema.optional().describe("Legend configuration"),
|
|
3403
|
+
metadata: z.record(z.any()).optional().describe("Custom metadata")
|
|
3404
|
+
});
|
|
3405
|
+
var CircleLayerSchema = BaseLayerPropertiesSchema.extend({
|
|
3406
|
+
type: z.literal("circle").describe("Layer type"),
|
|
3407
|
+
paint: z.object({
|
|
3408
|
+
"circle-radius": NumberOrExpressionSchema.optional(),
|
|
3409
|
+
"circle-color": ColorOrExpressionSchema.optional(),
|
|
3410
|
+
"circle-blur": NumberOrExpressionSchema.optional(),
|
|
3411
|
+
"circle-opacity": NumberOrExpressionSchema.optional(),
|
|
3412
|
+
"circle-stroke-width": NumberOrExpressionSchema.optional(),
|
|
3413
|
+
"circle-stroke-color": ColorOrExpressionSchema.optional(),
|
|
3414
|
+
"circle-stroke-opacity": NumberOrExpressionSchema.optional(),
|
|
3415
|
+
"circle-pitch-scale": z.enum(["map", "viewport"]).optional(),
|
|
3416
|
+
"circle-pitch-alignment": z.enum(["map", "viewport"]).optional(),
|
|
3417
|
+
"circle-translate": z.tuple([z.number(), z.number()]).optional(),
|
|
3418
|
+
"circle-translate-anchor": z.enum(["map", "viewport"]).optional()
|
|
3419
|
+
}).passthrough().optional().describe("Circle paint properties"),
|
|
3420
|
+
layout: z.object({}).passthrough().optional().describe("Circle layout properties")
|
|
3421
|
+
}).passthrough();
|
|
3422
|
+
var LineLayerSchema = BaseLayerPropertiesSchema.extend({
|
|
3423
|
+
type: z.literal("line").describe("Layer type"),
|
|
3424
|
+
paint: z.object({
|
|
3425
|
+
"line-opacity": NumberOrExpressionSchema.optional(),
|
|
3426
|
+
"line-color": ColorOrExpressionSchema.optional(),
|
|
3427
|
+
"line-width": NumberOrExpressionSchema.optional(),
|
|
3428
|
+
"line-gap-width": NumberOrExpressionSchema.optional(),
|
|
3429
|
+
"line-offset": NumberOrExpressionSchema.optional(),
|
|
3430
|
+
"line-blur": NumberOrExpressionSchema.optional(),
|
|
3431
|
+
"line-dasharray": z.array(z.number()).optional(),
|
|
3432
|
+
"line-pattern": z.string().optional(),
|
|
3433
|
+
"line-gradient": ColorOrExpressionSchema.optional(),
|
|
3434
|
+
"line-translate": z.tuple([z.number(), z.number()]).optional(),
|
|
3435
|
+
"line-translate-anchor": z.enum(["map", "viewport"]).optional()
|
|
3436
|
+
}).passthrough().optional().describe("Line paint properties"),
|
|
3437
|
+
layout: z.object({
|
|
3438
|
+
"line-cap": z.enum(["butt", "round", "square"]).optional(),
|
|
3439
|
+
"line-join": z.enum(["bevel", "round", "miter"]).optional(),
|
|
3440
|
+
"line-miter-limit": z.number().optional(),
|
|
3441
|
+
"line-round-limit": z.number().optional(),
|
|
3442
|
+
"line-sort-key": NumberOrExpressionSchema.optional()
|
|
3443
|
+
}).passthrough().optional().describe("Line layout properties")
|
|
3444
|
+
}).passthrough();
|
|
3445
|
+
var FillLayerSchema = BaseLayerPropertiesSchema.extend({
|
|
3446
|
+
type: z.literal("fill").describe("Layer type"),
|
|
3447
|
+
paint: z.object({
|
|
3448
|
+
"fill-antialias": z.boolean().optional(),
|
|
3449
|
+
"fill-opacity": NumberOrExpressionSchema.optional(),
|
|
3450
|
+
"fill-color": ColorOrExpressionSchema.optional(),
|
|
3451
|
+
"fill-outline-color": ColorOrExpressionSchema.optional(),
|
|
3452
|
+
"fill-translate": z.tuple([z.number(), z.number()]).optional(),
|
|
3453
|
+
"fill-translate-anchor": z.enum(["map", "viewport"]).optional(),
|
|
3454
|
+
"fill-pattern": z.string().optional()
|
|
3455
|
+
}).passthrough().optional().describe("Fill paint properties"),
|
|
3456
|
+
layout: z.object({
|
|
3457
|
+
"fill-sort-key": NumberOrExpressionSchema.optional()
|
|
3458
|
+
}).passthrough().optional().describe("Fill layout properties")
|
|
3459
|
+
}).passthrough();
|
|
3460
|
+
var SymbolLayerSchema = BaseLayerPropertiesSchema.extend({
|
|
3461
|
+
type: z.literal("symbol").describe("Layer type"),
|
|
3462
|
+
layout: z.object({
|
|
3463
|
+
"symbol-placement": z.enum(["point", "line", "line-center"]).optional(),
|
|
3464
|
+
"symbol-spacing": z.number().optional(),
|
|
3465
|
+
"symbol-avoid-edges": z.boolean().optional(),
|
|
3466
|
+
"symbol-sort-key": NumberOrExpressionSchema.optional(),
|
|
3467
|
+
"symbol-z-order": z.enum(["auto", "viewport-y", "source"]).optional(),
|
|
3468
|
+
"icon-allow-overlap": z.boolean().optional(),
|
|
3469
|
+
"icon-ignore-placement": z.boolean().optional(),
|
|
3470
|
+
"icon-optional": z.boolean().optional(),
|
|
3471
|
+
"icon-rotation-alignment": z.enum(["map", "viewport", "auto"]).optional(),
|
|
3472
|
+
"icon-size": NumberOrExpressionSchema.optional(),
|
|
3473
|
+
"icon-text-fit": z.enum(["none", "width", "height", "both"]).optional(),
|
|
3474
|
+
"icon-text-fit-padding": z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
|
|
3475
|
+
"icon-image": z.union([z.string(), ExpressionSchema]).optional(),
|
|
3476
|
+
"icon-rotate": NumberOrExpressionSchema.optional(),
|
|
3477
|
+
"icon-padding": z.number().optional(),
|
|
3478
|
+
"icon-keep-upright": z.boolean().optional(),
|
|
3479
|
+
"icon-offset": z.tuple([z.number(), z.number()]).optional(),
|
|
3480
|
+
"icon-anchor": z.enum([
|
|
3481
|
+
"center",
|
|
3482
|
+
"left",
|
|
3483
|
+
"right",
|
|
3484
|
+
"top",
|
|
3485
|
+
"bottom",
|
|
3486
|
+
"top-left",
|
|
3487
|
+
"top-right",
|
|
3488
|
+
"bottom-left",
|
|
3489
|
+
"bottom-right"
|
|
3490
|
+
]).optional(),
|
|
3491
|
+
"icon-pitch-alignment": z.enum(["map", "viewport", "auto"]).optional(),
|
|
3492
|
+
"text-pitch-alignment": z.enum(["map", "viewport", "auto"]).optional(),
|
|
3493
|
+
"text-rotation-alignment": z.enum(["map", "viewport", "auto"]).optional(),
|
|
3494
|
+
"text-field": z.union([z.string(), ExpressionSchema]).optional(),
|
|
3495
|
+
"text-font": z.array(z.string()).optional(),
|
|
3496
|
+
"text-size": NumberOrExpressionSchema.optional(),
|
|
3497
|
+
"text-max-width": NumberOrExpressionSchema.optional(),
|
|
3498
|
+
"text-line-height": z.number().optional(),
|
|
3499
|
+
"text-letter-spacing": z.number().optional(),
|
|
3500
|
+
"text-justify": z.enum(["auto", "left", "center", "right"]).optional(),
|
|
3501
|
+
"text-radial-offset": z.number().optional(),
|
|
3502
|
+
"text-variable-anchor": z.array(
|
|
3503
|
+
z.enum([
|
|
3504
|
+
"center",
|
|
3505
|
+
"left",
|
|
3506
|
+
"right",
|
|
3507
|
+
"top",
|
|
3508
|
+
"bottom",
|
|
3509
|
+
"top-left",
|
|
3510
|
+
"top-right",
|
|
3511
|
+
"bottom-left",
|
|
3512
|
+
"bottom-right"
|
|
3513
|
+
])
|
|
3514
|
+
).optional(),
|
|
3515
|
+
"text-anchor": z.enum([
|
|
3516
|
+
"center",
|
|
3517
|
+
"left",
|
|
3518
|
+
"right",
|
|
3519
|
+
"top",
|
|
3520
|
+
"bottom",
|
|
3521
|
+
"top-left",
|
|
3522
|
+
"top-right",
|
|
3523
|
+
"bottom-left",
|
|
3524
|
+
"bottom-right"
|
|
3525
|
+
]).optional(),
|
|
3526
|
+
"text-max-angle": z.number().optional(),
|
|
3527
|
+
"text-rotate": NumberOrExpressionSchema.optional(),
|
|
3528
|
+
"text-padding": z.number().optional(),
|
|
3529
|
+
"text-keep-upright": z.boolean().optional(),
|
|
3530
|
+
"text-transform": z.enum(["none", "uppercase", "lowercase"]).optional(),
|
|
3531
|
+
"text-offset": z.tuple([z.number(), z.number()]).optional(),
|
|
3532
|
+
"text-allow-overlap": z.boolean().optional(),
|
|
3533
|
+
"text-ignore-placement": z.boolean().optional(),
|
|
3534
|
+
"text-optional": z.boolean().optional()
|
|
3535
|
+
}).passthrough().optional().describe("Symbol layout properties"),
|
|
3536
|
+
paint: z.object({
|
|
3537
|
+
"icon-opacity": NumberOrExpressionSchema.optional(),
|
|
3538
|
+
"icon-color": ColorOrExpressionSchema.optional(),
|
|
3539
|
+
"icon-halo-color": ColorOrExpressionSchema.optional(),
|
|
3540
|
+
"icon-halo-width": NumberOrExpressionSchema.optional(),
|
|
3541
|
+
"icon-halo-blur": NumberOrExpressionSchema.optional(),
|
|
3542
|
+
"icon-translate": z.tuple([z.number(), z.number()]).optional(),
|
|
3543
|
+
"icon-translate-anchor": z.enum(["map", "viewport"]).optional(),
|
|
3544
|
+
"text-opacity": NumberOrExpressionSchema.optional(),
|
|
3545
|
+
"text-color": ColorOrExpressionSchema.optional(),
|
|
3546
|
+
"text-halo-color": ColorOrExpressionSchema.optional(),
|
|
3547
|
+
"text-halo-width": NumberOrExpressionSchema.optional(),
|
|
3548
|
+
"text-halo-blur": NumberOrExpressionSchema.optional(),
|
|
3549
|
+
"text-translate": z.tuple([z.number(), z.number()]).optional(),
|
|
3550
|
+
"text-translate-anchor": z.enum(["map", "viewport"]).optional()
|
|
3551
|
+
}).passthrough().optional().describe("Symbol paint properties")
|
|
3552
|
+
}).passthrough();
|
|
3553
|
+
var RasterLayerSchema = BaseLayerPropertiesSchema.extend({
|
|
3554
|
+
type: z.literal("raster").describe("Layer type"),
|
|
3555
|
+
paint: z.object({
|
|
3556
|
+
"raster-opacity": NumberOrExpressionSchema.optional(),
|
|
3557
|
+
"raster-hue-rotate": NumberOrExpressionSchema.optional(),
|
|
3558
|
+
"raster-brightness-min": NumberOrExpressionSchema.optional(),
|
|
3559
|
+
"raster-brightness-max": NumberOrExpressionSchema.optional(),
|
|
3560
|
+
"raster-saturation": NumberOrExpressionSchema.optional(),
|
|
3561
|
+
"raster-contrast": NumberOrExpressionSchema.optional(),
|
|
3562
|
+
"raster-resampling": z.enum(["linear", "nearest"]).optional(),
|
|
3563
|
+
"raster-fade-duration": z.number().optional()
|
|
3564
|
+
}).passthrough().optional().describe("Raster paint properties"),
|
|
3565
|
+
layout: z.object({}).passthrough().optional().describe("Raster layout properties")
|
|
3566
|
+
}).passthrough();
|
|
3567
|
+
var FillExtrusionLayerSchema = BaseLayerPropertiesSchema.extend({
|
|
3568
|
+
type: z.literal("fill-extrusion").describe("Layer type"),
|
|
3569
|
+
paint: z.object({
|
|
3570
|
+
"fill-extrusion-opacity": NumberOrExpressionSchema.optional(),
|
|
3571
|
+
"fill-extrusion-color": ColorOrExpressionSchema.optional(),
|
|
3572
|
+
"fill-extrusion-translate": z.tuple([z.number(), z.number()]).optional(),
|
|
3573
|
+
"fill-extrusion-translate-anchor": z.enum(["map", "viewport"]).optional(),
|
|
3574
|
+
"fill-extrusion-pattern": z.string().optional(),
|
|
3575
|
+
"fill-extrusion-height": NumberOrExpressionSchema.optional(),
|
|
3576
|
+
"fill-extrusion-base": NumberOrExpressionSchema.optional(),
|
|
3577
|
+
"fill-extrusion-vertical-gradient": z.boolean().optional()
|
|
3578
|
+
}).passthrough().optional().describe("Fill extrusion paint properties"),
|
|
3579
|
+
layout: z.object({}).passthrough().optional().describe("Fill extrusion layout properties")
|
|
3580
|
+
}).passthrough();
|
|
3581
|
+
var HeatmapLayerSchema = BaseLayerPropertiesSchema.extend({
|
|
3582
|
+
type: z.literal("heatmap").describe("Layer type"),
|
|
3583
|
+
paint: z.object({
|
|
3584
|
+
"heatmap-radius": NumberOrExpressionSchema.optional(),
|
|
3585
|
+
"heatmap-weight": NumberOrExpressionSchema.optional(),
|
|
3586
|
+
"heatmap-intensity": NumberOrExpressionSchema.optional(),
|
|
3587
|
+
"heatmap-color": ColorOrExpressionSchema.optional(),
|
|
3588
|
+
"heatmap-opacity": NumberOrExpressionSchema.optional()
|
|
3589
|
+
}).passthrough().optional().describe("Heatmap paint properties"),
|
|
3590
|
+
layout: z.object({}).passthrough().optional().describe("Heatmap layout properties")
|
|
3591
|
+
}).passthrough();
|
|
3592
|
+
var HillshadeLayerSchema = BaseLayerPropertiesSchema.extend({
|
|
3593
|
+
type: z.literal("hillshade").describe("Layer type"),
|
|
3594
|
+
paint: z.object({
|
|
3595
|
+
"hillshade-illumination-direction": z.number().optional(),
|
|
3596
|
+
"hillshade-illumination-anchor": z.enum(["map", "viewport"]).optional(),
|
|
3597
|
+
"hillshade-exaggeration": NumberOrExpressionSchema.optional(),
|
|
3598
|
+
"hillshade-shadow-color": ColorOrExpressionSchema.optional(),
|
|
3599
|
+
"hillshade-highlight-color": ColorOrExpressionSchema.optional(),
|
|
3600
|
+
"hillshade-accent-color": ColorOrExpressionSchema.optional()
|
|
3601
|
+
}).passthrough().optional().describe("Hillshade paint properties"),
|
|
3602
|
+
layout: z.object({}).passthrough().optional().describe("Hillshade layout properties")
|
|
3603
|
+
}).passthrough();
|
|
3604
|
+
var BackgroundLayerSchema = z.object({
|
|
3605
|
+
id: z.string().describe("Unique layer identifier"),
|
|
3606
|
+
type: z.literal("background").describe("Layer type"),
|
|
3607
|
+
paint: z.object({
|
|
3608
|
+
"background-color": ColorOrExpressionSchema.optional(),
|
|
3609
|
+
"background-pattern": z.string().optional(),
|
|
3610
|
+
"background-opacity": NumberOrExpressionSchema.optional()
|
|
3611
|
+
}).passthrough().optional().describe("Background paint properties"),
|
|
3612
|
+
layout: z.object({}).passthrough().optional().describe("Background layout properties"),
|
|
3613
|
+
metadata: z.record(z.any()).optional().describe("Custom metadata")
|
|
3614
|
+
}).passthrough();
|
|
3615
|
+
var LayerSchema = z.union([
|
|
3616
|
+
CircleLayerSchema,
|
|
3617
|
+
LineLayerSchema,
|
|
3618
|
+
FillLayerSchema,
|
|
3619
|
+
SymbolLayerSchema,
|
|
3620
|
+
RasterLayerSchema,
|
|
3621
|
+
FillExtrusionLayerSchema,
|
|
3622
|
+
HeatmapLayerSchema,
|
|
3623
|
+
HillshadeLayerSchema,
|
|
3624
|
+
BackgroundLayerSchema
|
|
3625
|
+
]);
|
|
3626
|
+
var LayerReferenceSchema = z.object({
|
|
3627
|
+
$ref: z.string().describe('Reference to global layer (e.g., "#/layers/bikeLayer")')
|
|
3628
|
+
}).describe("Layer reference");
|
|
3629
|
+
var LayerOrReferenceSchema = z.union([
|
|
3630
|
+
LayerSchema,
|
|
3631
|
+
LayerReferenceSchema
|
|
3632
|
+
]);
|
|
3633
|
+
|
|
3634
|
+
// src/schemas/map.schema.ts
|
|
3635
|
+
var ControlPositionSchema = z.enum([
|
|
3636
|
+
"top-left",
|
|
3637
|
+
"top-right",
|
|
3638
|
+
"bottom-left",
|
|
3639
|
+
"bottom-right"
|
|
3640
|
+
]);
|
|
3641
|
+
var ControlConfigSchema = z.union([
|
|
3642
|
+
z.boolean(),
|
|
3643
|
+
z.object({
|
|
3644
|
+
enabled: z.boolean().describe("Whether control is enabled"),
|
|
3645
|
+
position: ControlPositionSchema.optional().describe("Control position")
|
|
3646
|
+
})
|
|
3647
|
+
]);
|
|
3648
|
+
var ControlsConfigSchema = z.object({
|
|
3649
|
+
navigation: ControlConfigSchema.optional().describe(
|
|
3650
|
+
"Navigation controls (zoom, rotation)"
|
|
3651
|
+
),
|
|
3652
|
+
geolocate: ControlConfigSchema.optional().describe("Geolocation control"),
|
|
3653
|
+
scale: ControlConfigSchema.optional().describe("Scale control"),
|
|
3654
|
+
fullscreen: ControlConfigSchema.optional().describe("Fullscreen control"),
|
|
3655
|
+
attribution: ControlConfigSchema.optional().describe("Attribution control")
|
|
3656
|
+
}).describe("Map controls configuration");
|
|
3657
|
+
var LegendConfigSchema = z.object({
|
|
3658
|
+
position: ControlPositionSchema.default("top-left").describe("Legend position"),
|
|
3659
|
+
title: z.string().optional().describe("Legend title"),
|
|
3660
|
+
collapsed: z.boolean().default(false).describe("Start collapsed"),
|
|
3661
|
+
items: z.array(
|
|
3662
|
+
z.object({
|
|
3663
|
+
color: z.string().describe("Item color"),
|
|
3664
|
+
label: z.string().describe("Item label"),
|
|
3665
|
+
shape: z.enum(["circle", "square", "line", "icon"]).default("square").describe("Symbol shape"),
|
|
3666
|
+
icon: z.string().optional().describe("Icon name/URL (for shape: icon)")
|
|
3667
|
+
})
|
|
3668
|
+
).optional().describe("Custom legend items (overrides layer legends)")
|
|
3669
|
+
}).describe("Legend configuration");
|
|
3670
|
+
var MapConfigSchema = z.object({
|
|
3671
|
+
// Required
|
|
3672
|
+
center: LngLatSchema.describe("Initial map center [longitude, latitude]"),
|
|
3673
|
+
zoom: ZoomLevelSchema.describe("Initial zoom level (0-24)"),
|
|
3674
|
+
mapStyle: z.union([z.string().url(), z.any()]).describe("MapLibre style URL or style object"),
|
|
3675
|
+
// View
|
|
3676
|
+
pitch: z.number().min(0).max(85).default(0).describe("Camera pitch angle in degrees (0-85)"),
|
|
3677
|
+
bearing: z.number().min(-180).max(180).default(0).describe("Camera bearing (rotation) in degrees (-180 to 180)"),
|
|
3678
|
+
bounds: z.union([LngLatBoundsSchema, z.array(z.number())]).optional().describe("Fit map to bounds"),
|
|
3679
|
+
// Constraints
|
|
3680
|
+
minZoom: ZoomLevelSchema.optional().describe("Minimum zoom level"),
|
|
3681
|
+
maxZoom: ZoomLevelSchema.optional().describe("Maximum zoom level"),
|
|
3682
|
+
minPitch: z.number().min(0).max(85).optional().describe("Minimum pitch"),
|
|
3683
|
+
maxPitch: z.number().min(0).max(85).optional().describe("Maximum pitch"),
|
|
3684
|
+
maxBounds: LngLatBoundsSchema.optional().describe(
|
|
3685
|
+
"Maximum geographic bounds"
|
|
3686
|
+
),
|
|
3687
|
+
// Interaction
|
|
3688
|
+
interactive: z.boolean().default(true).describe("Enable map interaction"),
|
|
3689
|
+
scrollZoom: z.boolean().optional().describe("Enable scroll to zoom"),
|
|
3690
|
+
boxZoom: z.boolean().optional().describe("Enable box zoom (shift+drag)"),
|
|
3691
|
+
dragRotate: z.boolean().optional().describe("Enable drag to rotate"),
|
|
3692
|
+
dragPan: z.boolean().optional().describe("Enable drag to pan"),
|
|
3693
|
+
keyboard: z.boolean().optional().describe("Enable keyboard shortcuts"),
|
|
3694
|
+
doubleClickZoom: z.boolean().optional().describe("Enable double-click zoom"),
|
|
3695
|
+
touchZoomRotate: z.boolean().optional().describe("Enable touch zoom/rotate"),
|
|
3696
|
+
touchPitch: z.boolean().optional().describe("Enable touch pitch"),
|
|
3697
|
+
// Display
|
|
3698
|
+
hash: z.boolean().optional().describe("Sync map state with URL hash"),
|
|
3699
|
+
attributionControl: z.boolean().optional().describe("Show attribution control"),
|
|
3700
|
+
logoPosition: ControlPositionSchema.optional().describe(
|
|
3701
|
+
"MapLibre logo position"
|
|
3702
|
+
),
|
|
3703
|
+
fadeDuration: z.number().optional().describe("Fade duration in milliseconds"),
|
|
3704
|
+
crossSourceCollisions: z.boolean().optional().describe("Check for cross-source collisions"),
|
|
3705
|
+
// Rendering
|
|
3706
|
+
antialias: z.boolean().optional().describe("Enable antialiasing"),
|
|
3707
|
+
refreshExpiredTiles: z.boolean().optional().describe("Refresh expired tiles"),
|
|
3708
|
+
renderWorldCopies: z.boolean().optional().describe("Render multiple world copies"),
|
|
3709
|
+
locale: z.record(z.string()).optional().describe("Localization strings"),
|
|
3710
|
+
// Performance
|
|
3711
|
+
maxTileCacheSize: z.number().optional().describe("Maximum tiles to cache"),
|
|
3712
|
+
localIdeographFontFamily: z.string().optional().describe("Font for CJK characters"),
|
|
3713
|
+
trackResize: z.boolean().optional().describe("Track container resize"),
|
|
3714
|
+
preserveDrawingBuffer: z.boolean().optional().describe("Preserve drawing buffer"),
|
|
3715
|
+
failIfMajorPerformanceCaveat: z.boolean().optional().describe("Fail if major performance caveat")
|
|
3716
|
+
}).passthrough().describe("Map configuration with MapLibre options");
|
|
3717
|
+
var MapBlockSchema = z.object({
|
|
3718
|
+
type: z.literal("map").describe("Block type"),
|
|
3719
|
+
id: z.string().describe("Unique block identifier"),
|
|
3720
|
+
className: z.string().optional().describe("CSS class name for container"),
|
|
3721
|
+
style: z.string().optional().describe("Inline CSS styles for container"),
|
|
3722
|
+
config: MapConfigSchema.describe("Map configuration"),
|
|
3723
|
+
layers: z.array(LayerOrReferenceSchema).default([]).describe("Map layers"),
|
|
3724
|
+
controls: ControlsConfigSchema.optional().describe("Map controls"),
|
|
3725
|
+
legend: LegendConfigSchema.optional().describe("Legend configuration")
|
|
3726
|
+
}).describe("Standard map block");
|
|
3727
|
+
var MapFullPageBlockSchema = z.object({
|
|
3728
|
+
type: z.literal("map-fullpage").describe("Block type"),
|
|
3729
|
+
id: z.string().describe("Unique block identifier"),
|
|
3730
|
+
className: z.string().optional().describe("CSS class name for container"),
|
|
3731
|
+
style: z.string().optional().describe("Inline CSS styles for container"),
|
|
3732
|
+
config: MapConfigSchema.describe("Map configuration"),
|
|
3733
|
+
layers: z.array(LayerOrReferenceSchema).default([]).describe("Map layers"),
|
|
3734
|
+
controls: ControlsConfigSchema.optional().describe("Map controls"),
|
|
3735
|
+
legend: LegendConfigSchema.optional().describe("Legend configuration")
|
|
3736
|
+
}).describe("Full-page map block");
|
|
3737
|
+
var ChapterActionSchema = z.object({
|
|
3738
|
+
action: z.enum([
|
|
3739
|
+
"setFilter",
|
|
3740
|
+
"setPaintProperty",
|
|
3741
|
+
"setLayoutProperty",
|
|
3742
|
+
"flyTo",
|
|
3743
|
+
"easeTo",
|
|
3744
|
+
"fitBounds",
|
|
3745
|
+
"custom"
|
|
3746
|
+
]).describe("Action type"),
|
|
3747
|
+
layer: z.string().optional().describe("Target layer ID"),
|
|
3748
|
+
property: z.string().optional().describe("Property name (for setPaintProperty/setLayoutProperty)"),
|
|
3749
|
+
value: z.any().optional().describe("Property value"),
|
|
3750
|
+
filter: ExpressionSchema.nullable().optional().describe("Filter expression (for setFilter, null to clear)"),
|
|
3751
|
+
bounds: z.array(z.number()).optional().describe("Bounds array (for fitBounds)"),
|
|
3752
|
+
options: z.record(z.any()).optional().describe("Additional options")
|
|
3753
|
+
}).describe("Chapter action for map state changes");
|
|
3754
|
+
var ChapterLayersSchema = z.object({
|
|
3755
|
+
show: z.array(z.string()).default([]).describe("Layer IDs to show"),
|
|
3756
|
+
hide: z.array(z.string()).default([]).describe("Layer IDs to hide")
|
|
3757
|
+
}).describe("Chapter layer visibility configuration");
|
|
3758
|
+
var ChapterSchema = z.object({
|
|
3759
|
+
// Required
|
|
3760
|
+
id: z.string().describe("Unique chapter identifier"),
|
|
3761
|
+
title: z.string().describe("Chapter title"),
|
|
3762
|
+
center: LngLatSchema.describe("Map center [longitude, latitude]"),
|
|
3763
|
+
zoom: z.number().describe("Zoom level"),
|
|
3764
|
+
// Content
|
|
3765
|
+
description: z.string().optional().describe("Chapter description (HTML/markdown supported)"),
|
|
3766
|
+
image: z.string().url().optional().describe("Hero image URL"),
|
|
3767
|
+
video: z.string().url().optional().describe("Video URL"),
|
|
3768
|
+
// Camera
|
|
3769
|
+
pitch: z.number().min(0).max(85).default(0).describe("Camera pitch angle (0-85)"),
|
|
3770
|
+
bearing: z.number().min(-180).max(180).default(0).describe("Camera bearing (-180 to 180)"),
|
|
3771
|
+
speed: z.number().min(0).max(2).default(0.6).describe("Animation speed multiplier (0-2)"),
|
|
3772
|
+
curve: z.number().min(0).max(2).default(1).describe("Animation curve (0=linear, 1=default, 2=steep)"),
|
|
3773
|
+
animation: z.enum(["flyTo", "easeTo", "jumpTo"]).default("flyTo").describe("Animation type"),
|
|
3774
|
+
// Rotation animation
|
|
3775
|
+
rotateAnimation: z.boolean().optional().describe("Enable continuous rotation animation"),
|
|
3776
|
+
spinGlobe: z.boolean().optional().describe("Spin globe animation (for low zoom levels)"),
|
|
3777
|
+
// Layout
|
|
3778
|
+
alignment: z.enum(["left", "right", "center", "full"]).default("center").describe("Content alignment"),
|
|
3779
|
+
hidden: z.boolean().default(false).describe("Hide chapter content (map-only)"),
|
|
3780
|
+
// Layers
|
|
3781
|
+
layers: ChapterLayersSchema.optional().describe("Layer visibility control"),
|
|
3782
|
+
// Actions
|
|
3783
|
+
onChapterEnter: z.array(ChapterActionSchema).default([]).describe("Actions when entering chapter"),
|
|
3784
|
+
onChapterExit: z.array(ChapterActionSchema).default([]).describe("Actions when exiting chapter"),
|
|
3785
|
+
// Custom
|
|
3786
|
+
callback: z.string().optional().describe("Custom callback function name")
|
|
3787
|
+
}).describe("Scrollytelling chapter");
|
|
3788
|
+
var ScrollytellingBlockSchema = z.object({
|
|
3789
|
+
type: z.literal("scrollytelling").describe("Block type"),
|
|
3790
|
+
id: z.string().describe("Unique block identifier"),
|
|
3791
|
+
className: z.string().optional().describe("CSS class name for container"),
|
|
3792
|
+
style: z.string().optional().describe("Inline CSS styles for container"),
|
|
3793
|
+
// Base config
|
|
3794
|
+
config: MapConfigSchema.describe("Base map configuration"),
|
|
3795
|
+
// Theme
|
|
3796
|
+
theme: z.enum(["light", "dark"]).default("light").describe("Visual theme"),
|
|
3797
|
+
// Markers
|
|
3798
|
+
showMarkers: z.boolean().default(false).describe("Show chapter markers on map"),
|
|
3799
|
+
markerColor: z.string().default("#3FB1CE").describe("Chapter marker color"),
|
|
3800
|
+
// Layers (persistent across all chapters)
|
|
3801
|
+
layers: z.array(LayerOrReferenceSchema).default([]).describe("Persistent layers (visible throughout story)"),
|
|
3802
|
+
// Chapters
|
|
3803
|
+
chapters: z.array(ChapterSchema).min(1, "At least one chapter is required for scrollytelling").describe("Story chapters"),
|
|
3804
|
+
// Footer
|
|
3805
|
+
footer: z.string().optional().describe("Footer content (HTML)")
|
|
3806
|
+
}).describe("Scrollytelling block for narrative map stories");
|
|
3807
|
+
|
|
3808
|
+
// src/schemas/page.schema.ts
|
|
3809
|
+
var MixedBlockSchema = z.lazy(
|
|
3810
|
+
() => z.object({
|
|
3811
|
+
type: z.literal("mixed").describe("Block type"),
|
|
3812
|
+
id: z.string().optional().describe("Unique block identifier"),
|
|
3813
|
+
className: z.string().optional().describe("CSS class name for container"),
|
|
3814
|
+
style: z.string().optional().describe("Inline CSS styles for container"),
|
|
3815
|
+
layout: z.enum(["row", "column", "grid"]).default("row").describe("Layout direction"),
|
|
3816
|
+
gap: z.string().optional().describe("Gap between blocks (CSS gap property)"),
|
|
3817
|
+
blocks: z.array(BlockSchema).describe("Child blocks")
|
|
3818
|
+
}).describe("Mixed block for combining multiple block types")
|
|
3819
|
+
);
|
|
3820
|
+
var BlockSchema = z.union([
|
|
3821
|
+
ContentBlockSchema,
|
|
3822
|
+
MapBlockSchema,
|
|
3823
|
+
MapFullPageBlockSchema,
|
|
3824
|
+
ScrollytellingBlockSchema,
|
|
3825
|
+
MixedBlockSchema
|
|
3826
|
+
]);
|
|
3827
|
+
var PageSchema = z.object({
|
|
3828
|
+
path: z.string().describe('URL path (e.g., "/", "/about")'),
|
|
3829
|
+
title: z.string().describe("Page title"),
|
|
3830
|
+
description: z.string().optional().describe("Page description for SEO"),
|
|
3831
|
+
blocks: z.array(BlockSchema).describe("Page content blocks")
|
|
3832
|
+
}).describe("Page configuration");
|
|
3833
|
+
var GlobalConfigSchema = z.object({
|
|
3834
|
+
title: z.string().optional().describe("Application title"),
|
|
3835
|
+
description: z.string().optional().describe("Application description"),
|
|
3836
|
+
defaultMapStyle: z.string().url().optional().describe("Default map style URL"),
|
|
3837
|
+
theme: z.enum(["light", "dark"]).default("light").describe("Default theme"),
|
|
3838
|
+
dataFetching: z.object({
|
|
3839
|
+
defaultStrategy: z.enum(["runtime", "build", "hybrid"]).default("runtime").describe("Default fetch strategy"),
|
|
3840
|
+
timeout: z.number().min(1e3).default(3e4).describe("Default timeout in milliseconds"),
|
|
3841
|
+
retryAttempts: z.number().int().min(0).default(3).describe("Default retry attempts")
|
|
3842
|
+
}).optional().describe("Data fetching configuration")
|
|
3843
|
+
}).describe("Global configuration");
|
|
3844
|
+
var RootSchema = z.object({
|
|
3845
|
+
config: GlobalConfigSchema.optional().describe("Global configuration"),
|
|
3846
|
+
layers: z.record(LayerSchema).optional().describe("Named layer definitions for reuse"),
|
|
3847
|
+
sources: z.record(LayerSourceSchema).optional().describe("Named source definitions for reuse"),
|
|
3848
|
+
pages: z.array(PageSchema).min(1, "At least one page is required").describe("Page definitions")
|
|
3849
|
+
}).describe("Root configuration schema");
|
|
3850
|
+
|
|
3851
|
+
// src/parser/yaml-parser.ts
|
|
3852
|
+
var YAMLParser = class {
|
|
3853
|
+
/**
|
|
3854
|
+
* Parse YAML string and validate against schema
|
|
3855
|
+
*
|
|
3856
|
+
* @param yaml - YAML string to parse
|
|
3857
|
+
* @returns Validated configuration object
|
|
3858
|
+
* @throws {Error} If YAML syntax is invalid
|
|
3859
|
+
* @throws {ZodError} If validation fails
|
|
3860
|
+
*
|
|
3861
|
+
* @remarks
|
|
3862
|
+
* This method parses the YAML, validates it against the RootSchema,
|
|
3863
|
+
* resolves all references, and returns the validated config. If any
|
|
3864
|
+
* step fails, it throws an error.
|
|
3865
|
+
*
|
|
3866
|
+
* @example
|
|
3867
|
+
* ```typescript
|
|
3868
|
+
* try {
|
|
3869
|
+
* const config = YAMLParser.parse(yamlString);
|
|
3870
|
+
* console.log('Valid config:', config);
|
|
3871
|
+
* } catch (error) {
|
|
3872
|
+
* console.error('Parse error:', error.message);
|
|
3873
|
+
* }
|
|
3874
|
+
* ```
|
|
3875
|
+
*/
|
|
3876
|
+
static parse(yaml) {
|
|
3877
|
+
let parsed;
|
|
3878
|
+
try {
|
|
3879
|
+
parsed = parse(yaml);
|
|
3880
|
+
} catch (error) {
|
|
3881
|
+
throw new Error(
|
|
3882
|
+
`YAML syntax error: ${error instanceof Error ? error.message : String(error)}`
|
|
3883
|
+
);
|
|
3884
|
+
}
|
|
3885
|
+
const validated = RootSchema.parse(parsed);
|
|
3886
|
+
return this.resolveReferences(validated);
|
|
3887
|
+
}
|
|
3888
|
+
/**
|
|
3889
|
+
* Parse YAML string and validate, returning a result object
|
|
3890
|
+
*
|
|
3891
|
+
* @param yaml - YAML string to parse
|
|
3892
|
+
* @returns Result object with success flag and either data or errors
|
|
3893
|
+
*
|
|
3894
|
+
* @remarks
|
|
3895
|
+
* This is the non-throwing version of {@link parse}. Instead of throwing
|
|
3896
|
+
* errors, it returns a result object that indicates success or failure.
|
|
3897
|
+
* Use this when you want to handle errors gracefully without try/catch.
|
|
3898
|
+
*
|
|
3899
|
+
* @example
|
|
3900
|
+
* ```typescript
|
|
3901
|
+
* const result = YAMLParser.safeParse(yamlString);
|
|
3902
|
+
* if (result.success) {
|
|
3903
|
+
* console.log('Config:', result.data);
|
|
3904
|
+
* } else {
|
|
3905
|
+
* result.errors.forEach(err => {
|
|
3906
|
+
* console.error(`Error at ${err.path}: ${err.message}`);
|
|
3907
|
+
* });
|
|
3908
|
+
* }
|
|
3909
|
+
* ```
|
|
3910
|
+
*/
|
|
3911
|
+
static safeParse(yaml) {
|
|
3912
|
+
try {
|
|
3913
|
+
const data = this.parse(yaml);
|
|
3914
|
+
return {
|
|
3915
|
+
success: true,
|
|
3916
|
+
data,
|
|
3917
|
+
errors: []
|
|
3918
|
+
};
|
|
3919
|
+
} catch (error) {
|
|
3920
|
+
if (error instanceof ZodError) {
|
|
3921
|
+
return {
|
|
3922
|
+
success: false,
|
|
3923
|
+
errors: this.formatZodErrors(error)
|
|
3924
|
+
};
|
|
3925
|
+
}
|
|
3926
|
+
return {
|
|
3927
|
+
success: false,
|
|
3928
|
+
errors: [
|
|
3929
|
+
{
|
|
3930
|
+
path: "",
|
|
3931
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3932
|
+
}
|
|
3933
|
+
]
|
|
3934
|
+
};
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
/**
|
|
3938
|
+
* Validate a JavaScript object against the schema
|
|
3939
|
+
*
|
|
3940
|
+
* @param config - JavaScript object to validate
|
|
3941
|
+
* @returns Validated configuration object
|
|
3942
|
+
* @throws {ZodError} If validation fails
|
|
3943
|
+
*
|
|
3944
|
+
* @remarks
|
|
3945
|
+
* This method bypasses YAML parsing and directly validates a JavaScript object.
|
|
3946
|
+
* Useful when you already have a parsed object (e.g., from JSON.parse) and just
|
|
3947
|
+
* want to validate and resolve references.
|
|
3948
|
+
*
|
|
3949
|
+
* @example
|
|
3950
|
+
* ```typescript
|
|
3951
|
+
* const jsConfig = JSON.parse(jsonString);
|
|
3952
|
+
* const validated = YAMLParser.validate(jsConfig);
|
|
3953
|
+
* ```
|
|
3954
|
+
*/
|
|
3955
|
+
static validate(config) {
|
|
3956
|
+
const validated = RootSchema.parse(config);
|
|
3957
|
+
return this.resolveReferences(validated);
|
|
3958
|
+
}
|
|
3959
|
+
/**
|
|
3960
|
+
* Resolve $ref references to global layers and sources
|
|
3961
|
+
*
|
|
3962
|
+
* @param config - Configuration object with potential references
|
|
3963
|
+
* @returns Configuration with all references resolved
|
|
3964
|
+
* @throws {Error} If a reference cannot be resolved
|
|
3965
|
+
*
|
|
3966
|
+
* @remarks
|
|
3967
|
+
* References use JSON Pointer-like syntax: `#/layers/layerName` or `#/sources/sourceName`.
|
|
3968
|
+
* This method walks the configuration tree, finds all objects with a `$ref` property,
|
|
3969
|
+
* looks up the referenced item in `config.layers` or `config.sources`, and replaces
|
|
3970
|
+
* the reference object with the actual item.
|
|
3971
|
+
*
|
|
3972
|
+
* ## Reference Syntax
|
|
3973
|
+
*
|
|
3974
|
+
* - `#/layers/myLayer` - Reference to a layer in the global `layers` section
|
|
3975
|
+
* - `#/sources/mySource` - Reference to a source in the global `sources` section
|
|
3976
|
+
*
|
|
3977
|
+
* @example
|
|
3978
|
+
* ```typescript
|
|
3979
|
+
* const config = {
|
|
3980
|
+
* layers: {
|
|
3981
|
+
* myLayer: { id: 'layer1', type: 'circle', ... }
|
|
3982
|
+
* },
|
|
3983
|
+
* pages: [{
|
|
3984
|
+
* blocks: [{
|
|
3985
|
+
* type: 'map',
|
|
3986
|
+
* layers: [{ $ref: '#/layers/myLayer' }]
|
|
3987
|
+
* }]
|
|
3988
|
+
* }]
|
|
3989
|
+
* };
|
|
3990
|
+
*
|
|
3991
|
+
* const resolved = YAMLParser.resolveReferences(config);
|
|
3992
|
+
* // resolved.pages[0].blocks[0].layers[0] now contains the full layer object
|
|
3993
|
+
* ```
|
|
3994
|
+
*/
|
|
3995
|
+
static resolveReferences(config) {
|
|
3996
|
+
const resolveInObject = (obj) => {
|
|
3997
|
+
if (obj == null) return obj;
|
|
3998
|
+
if (Array.isArray(obj)) {
|
|
3999
|
+
return obj.map((item) => resolveInObject(item));
|
|
4000
|
+
}
|
|
4001
|
+
if (typeof obj === "object") {
|
|
4002
|
+
if ("$ref" in obj && typeof obj.$ref === "string") {
|
|
4003
|
+
const ref = obj.$ref;
|
|
4004
|
+
const match = ref.match(/^#\/(layers|sources)\/(.+)$/);
|
|
4005
|
+
if (!match) {
|
|
4006
|
+
throw new Error(
|
|
4007
|
+
`Invalid reference format: ${ref}. Expected #/layers/name or #/sources/name`
|
|
4008
|
+
);
|
|
4009
|
+
}
|
|
4010
|
+
const [, section, name] = match;
|
|
4011
|
+
if (section === "layers") {
|
|
4012
|
+
if (!config.layers || !(name in config.layers)) {
|
|
4013
|
+
throw new Error(`Layer reference not found: ${ref}`);
|
|
4014
|
+
}
|
|
4015
|
+
return config.layers[name];
|
|
4016
|
+
} else if (section === "sources") {
|
|
4017
|
+
if (!config.sources || !(name in config.sources)) {
|
|
4018
|
+
throw new Error(`Source reference not found: ${ref}`);
|
|
4019
|
+
}
|
|
4020
|
+
return config.sources[name];
|
|
4021
|
+
}
|
|
4022
|
+
}
|
|
4023
|
+
const resolved = {};
|
|
4024
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
4025
|
+
resolved[key] = resolveInObject(value);
|
|
4026
|
+
}
|
|
4027
|
+
return resolved;
|
|
4028
|
+
}
|
|
4029
|
+
return obj;
|
|
4030
|
+
};
|
|
4031
|
+
return resolveInObject(config);
|
|
4032
|
+
}
|
|
4033
|
+
/**
|
|
4034
|
+
* Format Zod validation errors into user-friendly messages
|
|
4035
|
+
*
|
|
4036
|
+
* @param error - Zod validation error
|
|
4037
|
+
* @returns Array of formatted error objects
|
|
4038
|
+
*
|
|
4039
|
+
* @remarks
|
|
4040
|
+
* This method transforms Zod's internal error format into human-readable
|
|
4041
|
+
* messages with clear paths and descriptions. It handles various Zod error
|
|
4042
|
+
* types and provides appropriate messages for each.
|
|
4043
|
+
*
|
|
4044
|
+
* ## Error Type Handling
|
|
4045
|
+
*
|
|
4046
|
+
* - `invalid_type`: Type mismatch (e.g., expected number, got string)
|
|
4047
|
+
* - `invalid_union_discriminator`: Invalid discriminator for union types
|
|
4048
|
+
* - `invalid_union`: None of the union options matched
|
|
4049
|
+
* - `too_small`: Value below minimum (arrays, strings, numbers)
|
|
4050
|
+
* - `too_big`: Value above maximum
|
|
4051
|
+
* - `invalid_string`: String format validation failed
|
|
4052
|
+
* - `custom`: Custom validation refinement failed
|
|
4053
|
+
*
|
|
4054
|
+
* @example
|
|
4055
|
+
* ```typescript
|
|
4056
|
+
* try {
|
|
4057
|
+
* RootSchema.parse(invalidConfig);
|
|
4058
|
+
* } catch (error) {
|
|
4059
|
+
* if (error instanceof ZodError) {
|
|
4060
|
+
* const formatted = YAMLParser.formatZodErrors(error);
|
|
4061
|
+
* formatted.forEach(err => {
|
|
4062
|
+
* console.error(`${err.path}: ${err.message}`);
|
|
4063
|
+
* });
|
|
4064
|
+
* }
|
|
4065
|
+
* }
|
|
4066
|
+
* ```
|
|
4067
|
+
*/
|
|
4068
|
+
static formatZodErrors(error) {
|
|
4069
|
+
return error.errors.map((err) => {
|
|
4070
|
+
const path = err.path.join(".");
|
|
4071
|
+
let message;
|
|
4072
|
+
switch (err.code) {
|
|
4073
|
+
case "invalid_type":
|
|
4074
|
+
message = `Expected ${err.expected}, got ${err.received}`;
|
|
4075
|
+
break;
|
|
4076
|
+
case "invalid_union_discriminator":
|
|
4077
|
+
message = `Invalid type. Expected one of: ${err.options.join(", ")}`;
|
|
4078
|
+
break;
|
|
4079
|
+
case "invalid_union":
|
|
4080
|
+
message = "Value does not match any of the expected formats";
|
|
4081
|
+
break;
|
|
4082
|
+
case "too_small":
|
|
4083
|
+
if (err.type === "array") {
|
|
4084
|
+
message = `Array must have at least ${err.minimum} element(s)`;
|
|
4085
|
+
} else if (err.type === "string") {
|
|
4086
|
+
message = `String must have at least ${err.minimum} character(s)`;
|
|
4087
|
+
} else {
|
|
4088
|
+
message = `Value must be >= ${err.minimum}`;
|
|
4089
|
+
}
|
|
4090
|
+
break;
|
|
4091
|
+
case "too_big":
|
|
4092
|
+
if (err.type === "array") {
|
|
4093
|
+
message = `Array must have at most ${err.maximum} element(s)`;
|
|
4094
|
+
} else if (err.type === "string") {
|
|
4095
|
+
message = `String must have at most ${err.maximum} character(s)`;
|
|
4096
|
+
} else {
|
|
4097
|
+
message = `Value must be <= ${err.maximum}`;
|
|
4098
|
+
}
|
|
4099
|
+
break;
|
|
4100
|
+
case "invalid_string":
|
|
4101
|
+
if (err.validation === "url") {
|
|
4102
|
+
message = "Invalid URL format";
|
|
4103
|
+
} else if (err.validation === "email") {
|
|
4104
|
+
message = "Invalid email format";
|
|
4105
|
+
} else {
|
|
4106
|
+
message = `Invalid string format: ${err.validation}`;
|
|
4107
|
+
}
|
|
4108
|
+
break;
|
|
4109
|
+
case "custom":
|
|
4110
|
+
message = err.message || "Validation failed";
|
|
4111
|
+
break;
|
|
4112
|
+
default:
|
|
4113
|
+
message = err.message || "Validation error";
|
|
4114
|
+
}
|
|
4115
|
+
return {
|
|
4116
|
+
path,
|
|
4117
|
+
message
|
|
4118
|
+
};
|
|
4119
|
+
});
|
|
4120
|
+
}
|
|
4121
|
+
};
|
|
4122
|
+
YAMLParser.parse.bind(YAMLParser);
|
|
4123
|
+
var safeParseYAMLConfig = YAMLParser.safeParse.bind(YAMLParser);
|
|
4124
|
+
|
|
4125
|
+
// src/components/ml-map.ts
|
|
4126
|
+
var MLMap = class extends HTMLElement {
|
|
4127
|
+
renderer = null;
|
|
4128
|
+
container = null;
|
|
4129
|
+
/**
|
|
4130
|
+
* Observed attributes that trigger attributeChangedCallback
|
|
4131
|
+
*/
|
|
4132
|
+
static get observedAttributes() {
|
|
4133
|
+
return ["config"];
|
|
4134
|
+
}
|
|
4135
|
+
/**
|
|
4136
|
+
* Called when element is added to the DOM
|
|
4137
|
+
*/
|
|
4138
|
+
connectedCallback() {
|
|
4139
|
+
this.container = document.createElement("div");
|
|
4140
|
+
this.container.style.width = "100%";
|
|
4141
|
+
this.container.style.height = "100%";
|
|
4142
|
+
this.appendChild(this.container);
|
|
4143
|
+
const config = this.getConfig();
|
|
4144
|
+
if (config) {
|
|
4145
|
+
this.render(config);
|
|
4146
|
+
} else {
|
|
4147
|
+
this.dispatchEvent(
|
|
4148
|
+
new CustomEvent("error", {
|
|
4149
|
+
detail: { error: new Error("No valid map configuration found") }
|
|
4150
|
+
})
|
|
4151
|
+
);
|
|
4152
|
+
}
|
|
4153
|
+
}
|
|
4154
|
+
/**
|
|
4155
|
+
* Called when element is removed from the DOM
|
|
4156
|
+
*/
|
|
4157
|
+
disconnectedCallback() {
|
|
4158
|
+
if (this.renderer) {
|
|
4159
|
+
this.renderer.destroy();
|
|
4160
|
+
this.renderer = null;
|
|
4161
|
+
}
|
|
4162
|
+
if (this.container) {
|
|
4163
|
+
this.container.remove();
|
|
4164
|
+
this.container = null;
|
|
4165
|
+
}
|
|
4166
|
+
}
|
|
4167
|
+
/**
|
|
4168
|
+
* Called when an observed attribute changes
|
|
4169
|
+
*/
|
|
4170
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
4171
|
+
if (name === "config" && oldValue !== newValue && this.container) {
|
|
4172
|
+
if (this.renderer) {
|
|
4173
|
+
this.renderer.destroy();
|
|
4174
|
+
this.renderer = null;
|
|
4175
|
+
}
|
|
4176
|
+
const config = this.getConfig();
|
|
4177
|
+
if (config) {
|
|
4178
|
+
this.render(config);
|
|
4179
|
+
}
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
/**
|
|
4183
|
+
* Get configuration from one of three sources:
|
|
4184
|
+
* 1. 'config' attribute (JSON string)
|
|
4185
|
+
* 2. Inline <script type="application/yaml">
|
|
4186
|
+
* 3. Inline <script type="application/json">
|
|
4187
|
+
*/
|
|
4188
|
+
getConfig() {
|
|
4189
|
+
const configAttr = this.getAttribute("config");
|
|
4190
|
+
if (configAttr) {
|
|
4191
|
+
try {
|
|
4192
|
+
const parsed = JSON.parse(configAttr);
|
|
4193
|
+
const result = MapBlockSchema.safeParse(parsed);
|
|
4194
|
+
if (result.success) {
|
|
4195
|
+
return result.data;
|
|
4196
|
+
} else {
|
|
4197
|
+
console.error("Invalid map config in attribute:", result.error);
|
|
4198
|
+
}
|
|
4199
|
+
} catch (error) {
|
|
4200
|
+
console.error("Failed to parse config attribute as JSON:", error);
|
|
4201
|
+
}
|
|
4202
|
+
}
|
|
4203
|
+
const yamlScript = this.querySelector(
|
|
4204
|
+
'script[type="application/yaml"]'
|
|
4205
|
+
);
|
|
4206
|
+
if (yamlScript?.textContent) {
|
|
4207
|
+
try {
|
|
4208
|
+
const parsed = safeParseYAMLConfig(yamlScript.textContent);
|
|
4209
|
+
if (parsed.success) {
|
|
4210
|
+
for (const page of parsed.data.pages || []) {
|
|
4211
|
+
const mapBlock = page.blocks?.find(
|
|
4212
|
+
(block) => block.type === "map"
|
|
4213
|
+
);
|
|
4214
|
+
if (mapBlock) {
|
|
4215
|
+
return mapBlock;
|
|
4216
|
+
}
|
|
4217
|
+
}
|
|
4218
|
+
} else {
|
|
4219
|
+
console.error("Invalid YAML config:", parsed.errors);
|
|
4220
|
+
}
|
|
4221
|
+
} catch (error) {
|
|
4222
|
+
console.error("Failed to parse YAML config:", error);
|
|
4223
|
+
}
|
|
4224
|
+
}
|
|
4225
|
+
const jsonScript = this.querySelector(
|
|
4226
|
+
'script[type="application/json"]'
|
|
4227
|
+
);
|
|
4228
|
+
if (jsonScript?.textContent) {
|
|
4229
|
+
try {
|
|
4230
|
+
const parsed = JSON.parse(jsonScript.textContent);
|
|
4231
|
+
const result = MapBlockSchema.safeParse(parsed);
|
|
4232
|
+
if (result.success) {
|
|
4233
|
+
return result.data;
|
|
4234
|
+
} else {
|
|
4235
|
+
console.error("Invalid JSON config:", result.error);
|
|
4236
|
+
}
|
|
4237
|
+
} catch (error) {
|
|
4238
|
+
console.error("Failed to parse JSON config:", error);
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4241
|
+
return null;
|
|
4242
|
+
}
|
|
4243
|
+
/**
|
|
4244
|
+
* Render the map with the given configuration
|
|
4245
|
+
*/
|
|
4246
|
+
render(config) {
|
|
4247
|
+
if (!this.container) return;
|
|
4248
|
+
try {
|
|
4249
|
+
this.renderer = new MapRenderer(
|
|
4250
|
+
this.container,
|
|
4251
|
+
config.config,
|
|
4252
|
+
config.layers || [],
|
|
4253
|
+
{
|
|
4254
|
+
onLoad: () => {
|
|
4255
|
+
if (config.controls) {
|
|
4256
|
+
this.renderer?.addControls(config.controls);
|
|
4257
|
+
}
|
|
4258
|
+
this.dispatchEvent(
|
|
4259
|
+
new CustomEvent("load", {
|
|
4260
|
+
detail: { map: this.renderer?.getMap() }
|
|
4261
|
+
})
|
|
4262
|
+
);
|
|
4263
|
+
},
|
|
4264
|
+
onError: (error) => {
|
|
4265
|
+
this.dispatchEvent(
|
|
4266
|
+
new CustomEvent("error", {
|
|
4267
|
+
detail: { error }
|
|
4268
|
+
})
|
|
4269
|
+
);
|
|
4270
|
+
}
|
|
4271
|
+
}
|
|
4272
|
+
);
|
|
4273
|
+
} catch (error) {
|
|
4274
|
+
this.dispatchEvent(
|
|
4275
|
+
new CustomEvent("error", {
|
|
4276
|
+
detail: { error }
|
|
4277
|
+
})
|
|
4278
|
+
);
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
/**
|
|
4282
|
+
* Get the underlying MapRenderer instance
|
|
4283
|
+
*/
|
|
4284
|
+
getRenderer() {
|
|
4285
|
+
return this.renderer;
|
|
4286
|
+
}
|
|
4287
|
+
/**
|
|
4288
|
+
* Get the underlying MapLibre Map instance
|
|
4289
|
+
*/
|
|
4290
|
+
getMap() {
|
|
4291
|
+
return this.renderer?.getMap() ?? null;
|
|
4292
|
+
}
|
|
4293
|
+
};
|
|
4294
|
+
if (typeof window !== "undefined" && !customElements.get("ml-map")) {
|
|
4295
|
+
customElements.define("ml-map", MLMap);
|
|
4296
|
+
}
|
|
4297
|
+
|
|
4298
|
+
// src/components/styles.ts
|
|
4299
|
+
var defaultStyles = `
|
|
4300
|
+
ml-map {
|
|
4301
|
+
display: block;
|
|
4302
|
+
position: relative;
|
|
4303
|
+
width: 100%;
|
|
4304
|
+
height: 400px;
|
|
4305
|
+
}
|
|
4306
|
+
|
|
4307
|
+
ml-map > div {
|
|
4308
|
+
width: 100%;
|
|
4309
|
+
height: 100%;
|
|
4310
|
+
}
|
|
4311
|
+
|
|
4312
|
+
ml-map script[type="application/yaml"],
|
|
4313
|
+
ml-map script[type="application/json"] {
|
|
4314
|
+
display: none;
|
|
4315
|
+
}
|
|
4316
|
+
`;
|
|
4317
|
+
function injectStyles() {
|
|
4318
|
+
if (typeof document === "undefined") return;
|
|
4319
|
+
const existingStyle = document.getElementById("ml-map-styles");
|
|
4320
|
+
if (existingStyle) return;
|
|
4321
|
+
const style = document.createElement("style");
|
|
4322
|
+
style.id = "ml-map-styles";
|
|
4323
|
+
style.textContent = defaultStyles;
|
|
4324
|
+
document.head.appendChild(style);
|
|
4325
|
+
}
|
|
4326
|
+
if (typeof window !== "undefined") {
|
|
4327
|
+
injectStyles();
|
|
4328
|
+
}
|
|
4329
|
+
|
|
4330
|
+
export { MLMap, defaultStyles, injectStyles };
|
|
4331
|
+
//# sourceMappingURL=index.js.map
|
|
4332
|
+
//# sourceMappingURL=index.js.map
|