@mmerlone/react-tz-globepicker 0.1.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.
@@ -0,0 +1,1213 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import zlib from "node:zlib";
4
+ import type { Readable } from "node:stream";
5
+ import * as topojsonServer from "topojson-server";
6
+ import * as topojsonClient from "topojson-client";
7
+ import * as topojsonSimplify from "topojson-simplify";
8
+ import { geoArea } from "d3-geo";
9
+ import type {
10
+ FeatureCollection,
11
+ Geometry,
12
+ GeoJsonProperties,
13
+ Feature,
14
+ Position,
15
+ } from "geojson";
16
+ import type { Topology, Objects } from "topojson-specification";
17
+ import { safeJsonParse } from "@/utils/json";
18
+ import {
19
+ getTimezoneCenter,
20
+ TIMEZONE_COORDINATES,
21
+ } from "../src/utils/timezoneCoordinates";
22
+
23
+ // Debug entry marker to confirm script execution when run via `pnpm run gen:globe`
24
+ console.log("[update-globe-data] entry");
25
+
26
+ // Handle process.exit properly for Node.js environment
27
+ const processExit = (code: number): never => {
28
+ process.exit(code);
29
+ };
30
+
31
+ /** Directory where generated timezone-related files live (src/data) */
32
+ const TZ_OUTPUT_DIR = path.join(process.cwd(), "src", "data");
33
+ const ETCGMT_OUTPUT_DIR = path.join(TZ_OUTPUT_DIR, "etcgmt-offset-geometries");
34
+ const IANA_TIMEZONES_OUTPUT_DIR = path.join(TZ_OUTPUT_DIR, "iana-timezones");
35
+ const CANONICAL_MARKERS_OUTPUT_PATH = path.join(
36
+ TZ_OUTPUT_DIR,
37
+ "canonical-markers.ts",
38
+ );
39
+ const COUNTRIES_OUTPUT_PATH = path.join(TZ_OUTPUT_DIR, "globe-countries.json");
40
+ const GEOGRAPHIC_IDL_OUTPUT_PATH = path.join(
41
+ TZ_OUTPUT_DIR,
42
+ "geographic-idl.json",
43
+ );
44
+
45
+ /** Directory where raw downloaded files are cached (src/data/raw) */
46
+ const RAW_DATA_DIR = path.join(TZ_OUTPUT_DIR, "raw");
47
+
48
+ /**
49
+ * Ensure the raw data directory exists
50
+ */
51
+ function ensureRawDataDir(): void {
52
+ if (!fs.existsSync(RAW_DATA_DIR)) {
53
+ fs.mkdirSync(RAW_DATA_DIR, { recursive: true });
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Replace a generated directory with a clean copy.
59
+ */
60
+ function resetGeneratedDir(dirPath: string): void {
61
+ fs.rmSync(dirPath, { recursive: true, force: true });
62
+ fs.mkdirSync(dirPath, { recursive: true });
63
+ }
64
+
65
+ /**
66
+ * Get the cache file path for a URL
67
+ */
68
+ function getCacheFilePath(url: string): string {
69
+ // Create a safe filename from the URL
70
+ const urlObj = new URL(url);
71
+ const filename = urlObj.pathname.split("/").pop() || "unknown";
72
+ return path.join(RAW_DATA_DIR, filename);
73
+ }
74
+
75
+ /**
76
+ * Check if a cached file exists
77
+ */
78
+ function getCachedFile(url: string): Buffer | null {
79
+ const cachePath = getCacheFilePath(url);
80
+ if (fs.existsSync(cachePath)) {
81
+ console.log(` Using cached: ${cachePath}`);
82
+ return fs.readFileSync(cachePath);
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Save content to cache
89
+ */
90
+ function saveToCache(url: string, content: Buffer): void {
91
+ ensureRawDataDir();
92
+ const cachePath = getCacheFilePath(url);
93
+ fs.writeFileSync(cachePath, content);
94
+ console.log(` Cached: ${cachePath}`);
95
+ }
96
+
97
+ /**
98
+ * Convert an offset key like `UTC+05:30` into a stable file stem.
99
+ */
100
+ function offsetKeyToFileStem(offsetKey: string): string {
101
+ const match = offsetKey.match(/^UTC([+-])(\d{2}):(\d{2})$/);
102
+ if (!match) {
103
+ throw new Error(`Unsupported ETC/GMT offset key: ${offsetKey}`);
104
+ }
105
+
106
+ const [, sign, hours, minutes] = match;
107
+ const signName = sign === "+" ? "plus" : "minus";
108
+ return `utc-${signName}-${hours}-${minutes}`;
109
+ }
110
+
111
+ /**
112
+ * Generate a typed loader module so bundlers can code-split each geometry file.
113
+ */
114
+ function buildEtcgmtLoaderModule(offsetKeys: string[]): string {
115
+ const imports = offsetKeys
116
+ .map((offsetKey) => {
117
+ const fileStem = offsetKeyToFileStem(offsetKey);
118
+ return ` ${JSON.stringify(offsetKey)}: () => import("./${fileStem}.json"),`;
119
+ })
120
+ .join("\n");
121
+
122
+ return `import type { FeatureCollection } from "geojson";
123
+
124
+ export type EtcGmtOffsetGeometryLoader = () => Promise<unknown>;
125
+
126
+ export const ETCGMT_OFFSET_KEYS = ${JSON.stringify(offsetKeys, null, 2)} as const;
127
+
128
+ export const ETCGMT_OFFSET_GEOMETRY_LOADERS: Record<
129
+ string,
130
+ EtcGmtOffsetGeometryLoader
131
+ > = {
132
+ ${imports}
133
+ };
134
+
135
+ function isFeatureCollection(value: unknown): value is FeatureCollection {
136
+ if (typeof value !== "object" || value === null) {
137
+ return false;
138
+ }
139
+
140
+ const candidate = value as Record<string, unknown>;
141
+ return (
142
+ candidate.type === "FeatureCollection" &&
143
+ Array.isArray(candidate.features)
144
+ );
145
+ }
146
+
147
+ export async function loadEtcGmtOffsetGeometry(
148
+ offsetKey: string,
149
+ ): Promise<FeatureCollection | null> {
150
+ const loader = ETCGMT_OFFSET_GEOMETRY_LOADERS[offsetKey];
151
+ if (!loader) {
152
+ return null;
153
+ }
154
+
155
+ const loadedModule = await loader();
156
+ if (
157
+ typeof loadedModule === "object" &&
158
+ loadedModule !== null &&
159
+ "default" in loadedModule
160
+ ) {
161
+ const defaultExport = (loadedModule as { default: unknown }).default;
162
+ if (isFeatureCollection(defaultExport)) {
163
+ return defaultExport;
164
+ }
165
+ }
166
+
167
+ return isFeatureCollection(loadedModule) ? loadedModule : null;
168
+ }
169
+ `;
170
+ }
171
+
172
+ function tzidToFileStem(tzid: string): string {
173
+ const encoded = encodeURIComponent(tzid).replace(/%/g, "_");
174
+ return `tz-${encoded}`;
175
+ }
176
+
177
+ /**
178
+ * Generate a typed loader module so bundlers can code-split each IANA file.
179
+ */
180
+ function buildIanaLoaderModule(tzids: string[]): string {
181
+ const imports = tzids
182
+ .map((tzid) => {
183
+ const fileStem = tzidToFileStem(tzid);
184
+ return ` ${JSON.stringify(tzid)}: () => import("./${fileStem}.json"),`;
185
+ })
186
+ .join("\n");
187
+
188
+ return `import type { FeatureCollection } from "geojson";
189
+
190
+ export type IanaTimezoneGeometryLoader = () => Promise<unknown>;
191
+
192
+ export const IANA_TIMEZONE_KEYS = ${JSON.stringify(tzids, null, 2)} as const;
193
+
194
+ export const IANA_TIMEZONE_GEOMETRY_LOADERS: Record<
195
+ string,
196
+ IanaTimezoneGeometryLoader
197
+ > = {
198
+ ${imports}
199
+ };
200
+
201
+ function isFeatureCollection(value: unknown): value is FeatureCollection {
202
+ if (typeof value !== "object" || value === null) {
203
+ return false;
204
+ }
205
+
206
+ const candidate = value as Record<string, unknown>;
207
+ return (
208
+ candidate.type === "FeatureCollection" &&
209
+ Array.isArray(candidate.features)
210
+ );
211
+ }
212
+
213
+ export async function loadIanaTimezoneGeometry(
214
+ tzid: string,
215
+ ): Promise<FeatureCollection | null> {
216
+ const loader = IANA_TIMEZONE_GEOMETRY_LOADERS[tzid];
217
+ if (!loader) {
218
+ return null;
219
+ }
220
+
221
+ const loadedModule = await loader();
222
+ if (
223
+ typeof loadedModule === "object" &&
224
+ loadedModule !== null &&
225
+ "default" in loadedModule
226
+ ) {
227
+ const defaultExport = (loadedModule as { default: unknown }).default;
228
+ if (isFeatureCollection(defaultExport)) {
229
+ return defaultExport;
230
+ }
231
+ }
232
+
233
+ return isFeatureCollection(loadedModule) ? loadedModule : null;
234
+ }
235
+ `;
236
+ }
237
+
238
+ function buildCanonicalMarkersModule(
239
+ markers: Array<{
240
+ tz: string;
241
+ coords: [number, number];
242
+ etcgmtOffsetKey?: string;
243
+ }>,
244
+ generatedAt: string,
245
+ ): string {
246
+ return `// Generated by scripts/update-globe-data.ts
247
+ // Generated At: ${generatedAt}
248
+
249
+ import type { MarkerEntry } from "../globe/types/globe.types";
250
+
251
+ export const CANONICAL_MARKERS: MarkerEntry[] = ${JSON.stringify(markers, null, 2)};
252
+ `;
253
+ }
254
+
255
+ function formatOffsetMinutesToIsoKey(minutes: number): string {
256
+ const sign = minutes >= 0 ? "+" : "-";
257
+ const absoluteMinutes = Math.abs(minutes);
258
+ const hours = String(Math.floor(absoluteMinutes / 60)).padStart(2, "0");
259
+ const remainingMinutes = String(absoluteMinutes % 60).padStart(2, "0");
260
+ return `UTC${sign}${hours}:${remainingMinutes}`;
261
+ }
262
+
263
+ function ianaToEtcForGeneration(iana: string): string {
264
+ if (!iana || typeof iana !== "string") {
265
+ throw new Error(`ianaToEtc: invalid timezone '${String(iana)}'`);
266
+ }
267
+
268
+ try {
269
+ let latitude = 0;
270
+ try {
271
+ latitude = getTimezoneCenter(iana)[0] ?? 0;
272
+ } catch {
273
+ latitude = 0;
274
+ }
275
+
276
+ const sampleDate =
277
+ latitude < 0
278
+ ? new Date(Date.UTC(2020, 6, 1, 12, 0, 0))
279
+ : new Date(Date.UTC(2020, 0, 1, 12, 0, 0));
280
+ const formatter = new Intl.DateTimeFormat("en-US", {
281
+ timeZone: iana,
282
+ timeZoneName: "longOffset",
283
+ });
284
+ const parts = formatter.formatToParts(sampleDate);
285
+ const timezonePart =
286
+ parts.find((part) => part.type === "timeZoneName")?.value ?? "";
287
+ const offsetMatch = timezonePart.match(/GMT([+-])(\d{2}):(\d{2})/);
288
+ if (!offsetMatch) {
289
+ return "UTC+00:00";
290
+ }
291
+
292
+ const [, signChar, hoursStr, minutesStr] = offsetMatch;
293
+ const sign = signChar === "+" ? 1 : -1;
294
+ const hours = parseInt(hoursStr ?? "0", 10);
295
+ const minutes = parseInt(minutesStr ?? "0", 10);
296
+ return formatOffsetMinutesToIsoKey(sign * (hours * 60 + minutes));
297
+ } catch (error) {
298
+ throw new Error(
299
+ `ianaToEtc: failed to compute offset for '${iana}': ${String(error)}`,
300
+ );
301
+ }
302
+ }
303
+
304
+ function offsetKeyFromEtcForGeneration(etcZone: string): string {
305
+ if (!etcZone || typeof etcZone !== "string") {
306
+ throw new Error(`offsetKeyFromEtc: invalid etcZone '${String(etcZone)}'`);
307
+ }
308
+
309
+ if (/^UTC[+-]\d{2}:\d{2}$/.test(etcZone)) {
310
+ return etcZone;
311
+ }
312
+ if (/^(Etc\/)?(GMT|UTC)$/.test(etcZone)) {
313
+ return "UTC+00:00";
314
+ }
315
+
316
+ const etcMatch = etcZone.match(/Etc\/GMT([+-])(\d{1,2})$/);
317
+ if (etcMatch) {
318
+ const [, signChar, numberStr] = etcMatch;
319
+ const hours = parseInt(numberStr ?? "0", 10);
320
+ const totalMinutes = signChar === "+" ? -hours * 60 : hours * 60;
321
+ return formatOffsetMinutesToIsoKey(totalMinutes);
322
+ }
323
+
324
+ const gmtMatch = etcZone.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
325
+ if (gmtMatch) {
326
+ const [, signChar, hoursStr, minutesStr] = gmtMatch;
327
+ const hours = parseInt(hoursStr ?? "0", 10);
328
+ const minutes = minutesStr ? parseInt(minutesStr, 10) : 0;
329
+ const sign = signChar === "+" ? 1 : -1;
330
+ return formatOffsetMinutesToIsoKey(sign * (hours * 60 + minutes));
331
+ }
332
+
333
+ throw new Error(`offsetKeyFromEtc: unsupported etcZone '${etcZone}'`);
334
+ }
335
+
336
+ /** Directory where generated timezone-related files live (src/data)
337
+ * We'll emit `src/data/iana-data.ts` as the canonical module for IANA regions.
338
+ */
339
+
340
+ /** Path to the visionscarto-world-atlas 110m TopoJSON in node_modules */
341
+ const WORLD_SOURCE = path.join(
342
+ process.cwd(),
343
+ "node_modules",
344
+ "visionscarto-world-atlas",
345
+ "world",
346
+ "110m.json",
347
+ );
348
+
349
+ /**
350
+ * IANA Timezone Boundaries (accurate IANA timezone polygons)
351
+ * Source: https://github.com/evansiroky/timezone-boundary-builder
352
+ * This provides accurate IANA timezone boundaries without ocean areas.
353
+ * Use this for TZ_BOUNDARY_MODES.IANA
354
+ */
355
+ const IANA_TZ_URL =
356
+ "https://github.com/evansiroky/timezone-boundary-builder/releases/download/2026a/timezones.geojson.zip";
357
+
358
+ /**
359
+ * ETC/GMT Timezone Polygons (simplified, includes ocean areas)
360
+ * Source: https://github.com/nvkelso/natural-earth-vector
361
+ * Standard source for high-quality, closed polygon timezone data.
362
+ * Use this for TZ_BOUNDARY_MODES.ETCGMT
363
+ */
364
+ const ETCGMT_TZ_URL =
365
+ "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_time_zones.geojson";
366
+
367
+ /** Natural Earth: geographic-lines (110m) - contains International Date Line feature(s) */
368
+ const GEOGRAPHIC_LINES_URL =
369
+ "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_geographic_lines.geojson";
370
+
371
+ /**
372
+ * Feature flag to control how offset geometries are generated for TZ_BOUNDARY_MODES.ETCGMT.
373
+ *
374
+ * Options:
375
+ * - "iana-only": Group IANA timezones by offset (default, recommended)
376
+ * - Uses IANA timezone boundaries which are accurate
377
+ * - Includes all IANA timezones (e.g., 15 timezones for UTC+07:00)
378
+ * - "natural-earth": Use Natural Earth data grouped by offset
379
+ * - Only has ~100 representative timezones
380
+ * - Missing many IANA timezones like Asia/Krasnoyarsk
381
+ * - "merge": Merge both Natural Earth and IANA data
382
+ * - Combines both sources for maximum coverage
383
+ */
384
+ const ETCGMT_OFFSET_SOURCE: "iana-only" | "natural-earth" | "merge" = "merge";
385
+ // const ETCGMT_OFFSET_SOURCE: "iana-only" | "natural-earth" | "merge" = "merge";
386
+ // const ETCGMT_OFFSET_SOURCE: "iana-only" | "natural-earth" | "merge" = "merge";
387
+ // ── Yauzl type definitions (yauzl doesn't have TypeScript types) ──
388
+
389
+ interface YauzlEntry {
390
+ fileName: string;
391
+ }
392
+
393
+ interface YauzlZipFile {
394
+ on(event: "entry", listener: (entry: YauzlEntry) => void): void;
395
+ on(event: "end", listener: () => void): void;
396
+ on(event: "error", listener: (err: Error) => void): void;
397
+ openReadStream(
398
+ entry: YauzlEntry,
399
+ callback: (
400
+ err: Error | undefined,
401
+ readStream: Readable | undefined,
402
+ ) => void,
403
+ ): void;
404
+ }
405
+
406
+ interface Yauzl {
407
+ fromBuffer(
408
+ buffer: Buffer,
409
+ callback: (err: Error | undefined, zipFile: YauzlZipFile) => void,
410
+ ): void;
411
+ }
412
+
413
+ // ── Helpers ──────────────────────────────────────────────────────
414
+
415
+ /**
416
+ * Download and unzip a GeoJSON file from a URL using yauzl
417
+ * Uses caching to avoid re-downloading
418
+ */
419
+ async function downloadAndUnzipGeoJson(
420
+ url: string,
421
+ ): Promise<FeatureCollection> {
422
+ // Check cache first
423
+ const cached = getCachedFile(url);
424
+ let buffer = cached;
425
+
426
+ if (!buffer) {
427
+ console.log(` Downloading: ${url}`);
428
+ const response = await fetch(url);
429
+ if (!response.ok) {
430
+ throw new Error(`Failed to download ${url}: ${response.statusText}`);
431
+ }
432
+
433
+ // Get the array buffer
434
+ const arrayBuffer = await response.arrayBuffer();
435
+ buffer = Buffer.from(arrayBuffer);
436
+
437
+ // Save to cache
438
+ saveToCache(url, buffer);
439
+ } else {
440
+ console.log(` Using cached: ${getCacheFilePath(url)}`);
441
+ }
442
+
443
+ // The zip file format: first 2 bytes = PK (0x50 0x4B)
444
+ const isZip = buffer[0] === 0x50 && buffer[1] === 0x4b;
445
+
446
+ let jsonText: string;
447
+
448
+ if (isZip) {
449
+ console.log(` Extracting zip file...`);
450
+
451
+ // Dynamically import yauzl for zip handling (ESM compatible)
452
+ const yauzlModule = await import("yauzl");
453
+ const yauzl = yauzlModule.default as Yauzl;
454
+
455
+ const zip = await new Promise<YauzlZipFile>((resolve, reject) => {
456
+ yauzl.fromBuffer(
457
+ buffer,
458
+ (err: Error | undefined, zipFile: YauzlZipFile | undefined) => {
459
+ if (err) reject(err);
460
+ else if (zipFile) resolve(zipFile);
461
+ else reject(new Error("Failed to open zip file"));
462
+ },
463
+ );
464
+ });
465
+
466
+ let geojsonData: Buffer | null = null;
467
+
468
+ // Use readEntry to iterate over entries (yauzl v3 API)
469
+ const entries: YauzlEntry[] = [];
470
+
471
+ await new Promise<void>((resolve, reject) => {
472
+ zip.on("entry", (entry: YauzlEntry) => {
473
+ entries.push(entry);
474
+ });
475
+ zip.on("end", () => resolve());
476
+ zip.on("error", reject);
477
+ });
478
+
479
+ for (const entry of entries) {
480
+ // The timezone-boundary-builder zip contains combined.json
481
+ if (
482
+ entry.fileName.endsWith(".json") ||
483
+ entry.fileName.endsWith(".geojson")
484
+ ) {
485
+ geojsonData = await new Promise<Buffer>((resolve, reject) => {
486
+ zip.openReadStream(entry, (err, readStream) => {
487
+ if (err) {
488
+ reject(err);
489
+ return;
490
+ }
491
+ if (!readStream) {
492
+ reject(new Error("Failed to open read stream"));
493
+ return;
494
+ }
495
+ const chunks: Buffer[] = [];
496
+ readStream.on("data", (chunk: Buffer) => chunks.push(chunk));
497
+ readStream.on("end", () => resolve(Buffer.concat(chunks)));
498
+ readStream.on("error", reject);
499
+ });
500
+ });
501
+ break;
502
+ }
503
+ }
504
+
505
+ if (!geojsonData) {
506
+ throw new Error("No .json or .geojson file found in zip");
507
+ }
508
+
509
+ jsonText = geojsonData.toString("utf-8");
510
+ } else if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
511
+ // Handle gzip file
512
+ jsonText = zlib.gunzipSync(buffer).toString("utf-8");
513
+ } else {
514
+ // Assume it's plain JSON
515
+ jsonText = buffer.toString("utf-8");
516
+ }
517
+
518
+ const parsed = safeJsonParse<FeatureCollection>(jsonText);
519
+ if (parsed === null) {
520
+ throw new Error(`Failed to parse JSON from ${url}`);
521
+ }
522
+ return parsed;
523
+ }
524
+
525
+ /**
526
+ * Download a regular GeoJSON file (not zipped)
527
+ * Uses caching to avoid re-downloading
528
+ */
529
+ async function downloadFile(url: string): Promise<FeatureCollection> {
530
+ // Check cache first
531
+ const cached = getCachedFile(url);
532
+ if (cached) {
533
+ console.log(` Using cached: ${getCacheFilePath(url)}`);
534
+ const jsonText = cached.toString("utf-8");
535
+ const parsed = safeJsonParse<FeatureCollection>(jsonText);
536
+ if (parsed === null) {
537
+ throw new Error(`Failed to parse cached JSON from ${url}`);
538
+ }
539
+ return parsed;
540
+ }
541
+
542
+ console.log(` Downloading: ${url}`);
543
+ const response = await fetch(url);
544
+ if (!response.ok) {
545
+ throw new Error(`Failed to download ${url}: ${response.statusText}`);
546
+ }
547
+
548
+ const jsonText = await response.text();
549
+
550
+ // Save to cache
551
+ saveToCache(url, Buffer.from(jsonText, "utf-8"));
552
+
553
+ const parsed = safeJsonParse<FeatureCollection>(jsonText);
554
+ if (parsed === null) {
555
+ throw new Error(`Failed to parse JSON from ${url}`);
556
+ }
557
+ return parsed;
558
+ }
559
+
560
+ /**
561
+ * Simplify topology while preserving shared borders.
562
+ */
563
+ function simplifyTopology(topology: Topology, quantileVal = 0.05): Topology {
564
+ // The topojson-simplify library expects Objects<object> but we have Objects<GeoJsonProperties>
565
+ // Cast to satisfy library requirements while maintaining our type structure
566
+ const topologyForLib = topology as Topology<Objects<object>>;
567
+ const presimplified = topojsonSimplify.presimplify(topologyForLib);
568
+ const minWeight = topojsonSimplify.quantile(presimplified, quantileVal);
569
+ const simplified = topojsonSimplify.simplify(presimplified, minWeight);
570
+ // Cast back to our original Topology type
571
+ return simplified as Topology;
572
+ }
573
+
574
+ /**
575
+ * Process timezone features - normalize IDs and reduce properties
576
+ */
577
+ function processTimezoneFeatures(
578
+ features: Feature<Geometry, GeoJsonProperties>[],
579
+ source: "iana" | "etcgmt",
580
+ ): Feature<Geometry, GeoJsonProperties>[] {
581
+ return features.map((f) => {
582
+ const p = (f.properties ?? {}) as Record<string, unknown>;
583
+
584
+ let tzid = "";
585
+ if (source === "iana") {
586
+ // IANA source: use tzid directly from properties
587
+ tzid = (p.tzid as string) ?? (p.timezone as string) ?? "";
588
+ } else {
589
+ // Natural Earth source: prefer canonical IANA name from tz_name1st
590
+ // Fall back to UTC format labels and then display name
591
+ tzid =
592
+ (p.tz_name1st as string) ??
593
+ (p.time_zone as string) ??
594
+ (p.name as string) ??
595
+ "";
596
+ }
597
+
598
+ return {
599
+ ...f,
600
+ properties: {
601
+ tzid,
602
+ name: (p.name as string) ?? "",
603
+ source, // Track which source this feature came from
604
+ },
605
+ };
606
+ });
607
+ }
608
+
609
+ /**
610
+ * Normalize geometry for a feature in-place.
611
+ * - Ensure polygon rings are closed
612
+ * - If area is abnormally large (sign of inverted winding or malformed geometry),
613
+ * reverse ring orders for Polygon/MultiPolygon parts
614
+ */
615
+ function normalizeFeatureGeo(
616
+ feature: Feature<Geometry, GeoJsonProperties> | { geometry?: Geometry },
617
+ ) {
618
+ if (!feature?.geometry) return feature;
619
+ const geom = feature.geometry;
620
+
621
+ function closeRing(ring: Position[]) {
622
+ if (!Array.isArray(ring) || ring.length === 0) return ring;
623
+ const first = ring[0] as Position;
624
+ const last = ring[ring.length - 1] as Position;
625
+ if (first[0] !== last[0] || first[1] !== last[1]) {
626
+ ring.push([first[0], first[1]] as Position);
627
+ }
628
+ return ring;
629
+ }
630
+
631
+ if (geom.type === "Polygon") {
632
+ geom.coordinates = geom.coordinates.map((ring) => closeRing(ring));
633
+ } else if (geom.type === "MultiPolygon") {
634
+ geom.coordinates = geom.coordinates.map((poly) =>
635
+ poly.map((ring) => closeRing(ring)),
636
+ );
637
+ }
638
+
639
+ // Use d3-geo's geoArea to detect obviously invalid/inverted geometries
640
+ try {
641
+ const area = Math.abs(
642
+ geoArea(feature as Feature<Geometry, GeoJsonProperties>),
643
+ );
644
+ // Steradian of full sphere ≈ 4π. If area is absurdly large (greater than half the sphere),
645
+ // treat as an indicator of inverted or malformed winding and attempt to reverse rings.
646
+ if (area > Math.PI * 2) {
647
+ if (geom.type === "Polygon") {
648
+ geom.coordinates = geom.coordinates.map((ring) => [...ring].reverse());
649
+ } else if (geom.type === "MultiPolygon") {
650
+ geom.coordinates = geom.coordinates.map((poly) =>
651
+ poly.map((ring) => [...ring].reverse()),
652
+ );
653
+ }
654
+ }
655
+ } catch (e) {
656
+ // Non-fatal: normalization best-effort
657
+ }
658
+
659
+ return feature;
660
+ }
661
+
662
+ // ── Main ──────────────────────────────────────────────────────────
663
+
664
+ export async function generateGlobeData(): Promise<void> {
665
+ console.log("🌍 Generating split globe artifacts...\n");
666
+ console.log(
667
+ " IANA Timezones: timezone-boundary-builder (accurate IANA boundaries)",
668
+ );
669
+ console.log(
670
+ " ETC/GMT Timezones: Natural Earth 10m (includes ocean areas)\n",
671
+ );
672
+
673
+ fs.mkdirSync(TZ_OUTPUT_DIR, { recursive: true });
674
+
675
+ try {
676
+ // 1. Load World 110m (Countries)
677
+ if (!fs.existsSync(WORLD_SOURCE)) {
678
+ throw new Error(`visionscarto-world-atlas not found at ${WORLD_SOURCE}`);
679
+ }
680
+ console.log("📦 Step 1: Loading World 110m (Countries)...");
681
+ const worldData = fs.readFileSync(WORLD_SOURCE, "utf-8");
682
+ const worldTopoParsed = safeJsonParse(worldData);
683
+ if (worldTopoParsed === null) {
684
+ throw new Error(`Failed to parse world topology from ${WORLD_SOURCE}`);
685
+ }
686
+ const worldTopo = worldTopoParsed as Topology;
687
+ // Extract logical "countries" layer
688
+ const countriesObject = worldTopo.objects["countries"];
689
+ if (!countriesObject) {
690
+ throw new Error("Countries object not found in world topology");
691
+ }
692
+ const countriesGeo = topojsonClient.feature(
693
+ worldTopo,
694
+ countriesObject,
695
+ ) as FeatureCollection<Geometry, GeoJsonProperties>;
696
+
697
+ // 2. Download IANA Timezone Boundaries (accurate, no oceans)
698
+ console.log("\n📦 Step 2: Downloading IANA Timezone Boundaries...");
699
+ console.log(` Source: ${IANA_TZ_URL}`);
700
+ const ianaTimezonesGeo = await downloadAndUnzipGeoJson(IANA_TZ_URL);
701
+ console.log(
702
+ ` Parsed ${ianaTimezonesGeo.features.length} IANA timezone features`,
703
+ );
704
+
705
+ // 3. Download ETC/GMT Timezone Polygons (Natural Earth, includes oceans)
706
+ console.log("\n📦 Step 3: Downloading ETC/GMT Timezone Polygons...");
707
+ console.log(` Source: ${ETCGMT_TZ_URL}`);
708
+ const etcgmtTimezonesGeo = await downloadFile(ETCGMT_TZ_URL);
709
+ console.log(
710
+ ` Parsed ${etcgmtTimezonesGeo.features.length} ETC/GMT timezone features`,
711
+ );
712
+
713
+ // 3.b Download Natural Earth geographic-lines (110m) to extract IDL
714
+ console.log(
715
+ "\n📦 Step 3b: Downloading Natural Earth geographic-lines (110m) …",
716
+ );
717
+ console.log(` Source: ${GEOGRAPHIC_LINES_URL}`);
718
+ const geographicLinesGeo = await downloadFile(GEOGRAPHIC_LINES_URL);
719
+ console.log(
720
+ ` Parsed ${geographicLinesGeo.features.length} geographic-lines features`,
721
+ );
722
+
723
+ // Persist raw geographic-lines to scripts/tmp for inspection
724
+ try {
725
+ const tmpDir = path.join(process.cwd(), "scripts", "tmp");
726
+ if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
727
+ const rawPath = path.join(tmpDir, "geographic-lines-110m.geojson");
728
+ fs.writeFileSync(rawPath, JSON.stringify(geographicLinesGeo, null, 2));
729
+ console.log(` ✅ Persisted geographic-lines to ${rawPath}`);
730
+ } catch (e) {
731
+ console.warn(
732
+ " ⚠️ Failed to persist geographic-lines for inspection:",
733
+ e,
734
+ );
735
+ }
736
+
737
+ // Identify International Date Line feature(s) in the geographic-lines file.
738
+ // Heuristic: match on name, featurecla, or any property containing 'date'+'line'.
739
+ const idlMatchRegex =
740
+ /international.*date\s*-?\s*line|date\s*-?\s*line|dateline/i;
741
+ const geographicIdlFeatures = geographicLinesGeo.features.filter((f) => {
742
+ const p = (f.properties ?? {}) as Record<string, unknown>;
743
+ for (const key of Object.keys(p)) {
744
+ const v = String(p[key] ?? "");
745
+ if (idlMatchRegex.test(v)) return true;
746
+ }
747
+ // Also check common name fields
748
+ if (typeof p.name === "string" && idlMatchRegex.test(p.name)) return true;
749
+ if (typeof p.featurecla === "string" && idlMatchRegex.test(p.featurecla))
750
+ return true;
751
+ return false;
752
+ });
753
+
754
+ const geographicIdlFc: FeatureCollection = {
755
+ type: "FeatureCollection",
756
+ features: geographicIdlFeatures,
757
+ };
758
+ console.log(
759
+ ` Extracted ${geographicIdlFeatures.length} candidate geographic_idl feature(s)`,
760
+ );
761
+
762
+ // 4. Process both timezone sources
763
+ console.log("\n📦 Step 4: Processing timezone features...");
764
+
765
+ // Process IANA timezones
766
+ const processedIanaFeatures = processTimezoneFeatures(
767
+ ianaTimezonesGeo.features,
768
+ "iana",
769
+ );
770
+ // Normalize IANA geometry features to ensure closed rings and reasonable winding
771
+ for (const f of processedIanaFeatures) normalizeFeatureGeo(f);
772
+ console.log(
773
+ ` Processed ${processedIanaFeatures.length} IANA timezone features`,
774
+ );
775
+
776
+ // Process ETC/GMT timezones
777
+ const processedEtcgmtFeatures = processTimezoneFeatures(
778
+ etcgmtTimezonesGeo.features,
779
+ "etcgmt",
780
+ );
781
+ console.log(
782
+ ` Processed ${processedEtcgmtFeatures.length} ETC/GMT timezone features`,
783
+ );
784
+
785
+ // 5. Create Combined Topology with both timezone sources
786
+ console.log(
787
+ "\n📦 Step 5: Building internal TopoJSON for simplification...",
788
+ );
789
+
790
+ // Create FeatureCollections for topology
791
+ const ianaFc: FeatureCollection = {
792
+ type: "FeatureCollection",
793
+ features: processedIanaFeatures,
794
+ };
795
+ const combinedTopology = topojsonServer.topology({
796
+ countries: countriesGeo,
797
+ iana_timezones: ianaFc,
798
+ geographic_idl: geographicIdlFc,
799
+ });
800
+
801
+ // 6. Simplify
802
+ // Reduce simplification aggressiveness to preserve finer IANA boundary detail.
803
+ // Lower quantile leads to less simplification; 0.04 preserves more vertices.
804
+ console.log(" Simplifying topology (quantile=0.04)...");
805
+ const simplified = simplifyTopology(combinedTopology, 0.04);
806
+
807
+ // Extract simplified IANA features for use in offset geometries
808
+ // (The detailed IANA features are too large to serialize to JSON)
809
+ const ianaTimezonesObj = simplified.objects.iana_timezones;
810
+ if (!ianaTimezonesObj) {
811
+ throw new Error("Topology missing iana_timezones object");
812
+ }
813
+ const simplifiedIanaFc = topojsonClient.feature(
814
+ simplified,
815
+ ianaTimezonesObj,
816
+ ) as unknown as FeatureCollection;
817
+ console.log(
818
+ ` Extracted ${simplifiedIanaFc.features.length} simplified IANA features for offset geometries`,
819
+ );
820
+
821
+ // 7. Generate runtime artifacts
822
+ const generatedAt = new Date().toISOString();
823
+
824
+ // 8. Verify required topology objects exist in the simplified topology
825
+ const requiredObjects = ["iana_timezones"];
826
+ for (const objName of requiredObjects) {
827
+ if (!simplified.objects || !simplified.objects[objName]) {
828
+ throw new Error(`Topology missing required object: ${objName}`);
829
+ }
830
+ }
831
+ fs.rmSync(path.join(TZ_OUTPUT_DIR, "globe-data.json"), { force: true });
832
+
833
+ const runtimeCountriesObject = simplified.objects.countries;
834
+ const runtimeIanaTimezonesObject = simplified.objects.iana_timezones;
835
+ if (!runtimeCountriesObject || !runtimeIanaTimezonesObject) {
836
+ throw new Error(
837
+ "Topology missing required objects for runtime artifacts",
838
+ );
839
+ }
840
+
841
+ const countriesFeatureCollection = topojsonClient.feature(
842
+ simplified,
843
+ runtimeCountriesObject,
844
+ ) as unknown as FeatureCollection;
845
+ const ianaTimezonesFeatureCollection = topojsonClient.feature(
846
+ simplified,
847
+ runtimeIanaTimezonesObject,
848
+ ) as unknown as FeatureCollection;
849
+ const geographicIdlFeatureCollection = simplified.objects.geographic_idl
850
+ ? (topojsonClient.feature(
851
+ simplified,
852
+ simplified.objects.geographic_idl,
853
+ ) as unknown as FeatureCollection)
854
+ : { type: "FeatureCollection", features: [] };
855
+
856
+ fs.writeFileSync(
857
+ COUNTRIES_OUTPUT_PATH,
858
+ JSON.stringify(countriesFeatureCollection),
859
+ );
860
+ fs.writeFileSync(
861
+ GEOGRAPHIC_IDL_OUTPUT_PATH,
862
+ JSON.stringify(geographicIdlFeatureCollection),
863
+ );
864
+ console.log(` ✅ Generated: ${COUNTRIES_OUTPUT_PATH}`);
865
+ console.log(` ✅ Generated: ${GEOGRAPHIC_IDL_OUTPUT_PATH}`);
866
+
867
+ const ianaFeatureCollectionsByTzid = new Map<string, FeatureCollection>();
868
+ let skippedIanaRuntimeFeatures = 0;
869
+ for (const feature of ianaTimezonesFeatureCollection.features) {
870
+ const tzid = feature.properties?.tzid;
871
+ if (typeof tzid !== "string" || tzid.length === 0) {
872
+ skippedIanaRuntimeFeatures++;
873
+ continue;
874
+ }
875
+
876
+ const existingFeatureCollection = ianaFeatureCollectionsByTzid.get(tzid);
877
+ if (existingFeatureCollection) {
878
+ existingFeatureCollection.features.push(feature);
879
+ continue;
880
+ }
881
+
882
+ ianaFeatureCollectionsByTzid.set(tzid, {
883
+ type: "FeatureCollection",
884
+ features: [feature],
885
+ });
886
+ }
887
+
888
+ resetGeneratedDir(IANA_TIMEZONES_OUTPUT_DIR);
889
+ const sortedIanaTzids = Array.from(
890
+ ianaFeatureCollectionsByTzid.keys(),
891
+ ).sort();
892
+ for (const tzid of sortedIanaTzids) {
893
+ const fileStem = tzidToFileStem(tzid);
894
+ const featurePath = path.join(
895
+ IANA_TIMEZONES_OUTPUT_DIR,
896
+ `${fileStem}.json`,
897
+ );
898
+ const featureCollection = ianaFeatureCollectionsByTzid.get(tzid);
899
+ if (!featureCollection) {
900
+ continue;
901
+ }
902
+
903
+ fs.writeFileSync(featurePath, JSON.stringify(featureCollection, null, 2));
904
+ }
905
+
906
+ const ianaLoaderModulePath = path.join(
907
+ IANA_TIMEZONES_OUTPUT_DIR,
908
+ "index.ts",
909
+ );
910
+ fs.writeFileSync(
911
+ ianaLoaderModulePath,
912
+ buildIanaLoaderModule(sortedIanaTzids),
913
+ );
914
+
915
+ fs.rmSync(path.join(TZ_OUTPUT_DIR, "iana-timezones.json"), { force: true });
916
+
917
+ console.log(
918
+ ` ✅ Generated: ${IANA_TIMEZONES_OUTPUT_DIR} (${sortedIanaTzids.length} timezone files)`,
919
+ );
920
+ if (skippedIanaRuntimeFeatures > 0) {
921
+ console.warn(
922
+ ` ⚠️ Skipped ${skippedIanaRuntimeFeatures} IANA runtime feature(s) without a tzid.`,
923
+ );
924
+ }
925
+
926
+ // 8. Generate canonical IANA module: `src/data/iana-data.ts`
927
+ console.log(
928
+ "\n📦 Step 8: Generating canonical IANA module (src/data/iana-data.ts)…",
929
+ );
930
+ const uniqueRegions = new Set<string>();
931
+ for (const f of processedIanaFeatures) {
932
+ const tzid = f.properties?.tzid as string | undefined;
933
+ if (tzid && tzid.length > 0) uniqueRegions.add(tzid);
934
+ }
935
+ if (uniqueRegions.size === 0) {
936
+ throw new Error(
937
+ "No IANA timezone regions were extracted — aborting canonical module generation.",
938
+ );
939
+ }
940
+
941
+ const sortedRegions = Array.from(uniqueRegions).sort();
942
+ // Validate canonical regions: drop any region names that Intl cannot resolve.
943
+ const validRegions: string[] = [];
944
+ for (const tz of sortedRegions) {
945
+ try {
946
+ // validate by attempting to compute canonical ETC key
947
+ ianaToEtcForGeneration(tz);
948
+ validRegions.push(tz);
949
+ } catch (err) {
950
+ console.warn(
951
+ ` ⚠️ Skipping invalid IANA region during generation: ${tz}`,
952
+ err,
953
+ );
954
+ }
955
+ }
956
+ const usedRegions = validRegions;
957
+ const ianaModulePath = path.join(TZ_OUTPUT_DIR, "iana-data.ts");
958
+ const ianaModuleContent = `// Generated by scripts/update-globe-data.ts
959
+ // Generated At: ${generatedAt}
960
+ // IANA Source: ${IANA_TZ_URL}
961
+ // ETC/GMT Source: ${ETCGMT_TZ_URL}
962
+
963
+ export const IANA_TZ_METADATA = {
964
+ generatedAt: "${generatedAt}",
965
+ generator: "scripts/update-globe-data.ts",
966
+ source: {
967
+ iana: "${IANA_TZ_URL}",
968
+ etcgmt: "${ETCGMT_TZ_URL}",
969
+ },
970
+ regionCount: ${usedRegions.length},
971
+ } as const
972
+
973
+ export const IANA_TZ_DATA = ${JSON.stringify(usedRegions, null, 2)} as const
974
+
975
+ export type IanaTzRegion = typeof IANA_TZ_DATA[number]
976
+ `;
977
+ fs.writeFileSync(ianaModulePath, ianaModuleContent);
978
+ console.log(
979
+ ` ✅ Generated: ${ianaModulePath} (${sortedRegions.length} regions)`,
980
+ );
981
+
982
+ const canonicalMarkers = usedRegions.flatMap((tzid) => {
983
+ const coords = TIMEZONE_COORDINATES[tzid];
984
+ if (!coords) {
985
+ return [];
986
+ }
987
+
988
+ const [lat, lng] = coords;
989
+ let etcgmtOffsetKey: string | undefined;
990
+ try {
991
+ etcgmtOffsetKey = ianaToEtcForGeneration(tzid);
992
+ } catch {
993
+ etcgmtOffsetKey = undefined;
994
+ }
995
+
996
+ return [
997
+ {
998
+ tz: tzid,
999
+ coords: [lat, lng] as [number, number],
1000
+ ...(etcgmtOffsetKey ? { etcgmtOffsetKey } : {}),
1001
+ },
1002
+ ];
1003
+ });
1004
+ fs.writeFileSync(
1005
+ CANONICAL_MARKERS_OUTPUT_PATH,
1006
+ buildCanonicalMarkersModule(canonicalMarkers, generatedAt),
1007
+ );
1008
+ console.log(
1009
+ ` ✅ Generated: ${CANONICAL_MARKERS_OUTPUT_PATH} (${canonicalMarkers.length} markers)`,
1010
+ );
1011
+
1012
+ // 9. Generate ETCGMT artifacts
1013
+ console.log("\n📦 Step 9: Generating ETCGMT artifacts...");
1014
+ console.log(` Mode: ETCGMT_OFFSET_SOURCE = "${ETCGMT_OFFSET_SOURCE}"`);
1015
+
1016
+ // Build map: isoKey (UTC±HH:MM) -> FeatureCollection
1017
+ const etcMap = new Map<string, FeatureCollection>();
1018
+ let skippedEtcFeatures = 0;
1019
+ let totalFeaturesAdded = 0;
1020
+
1021
+ // Helper to add a feature to the offset map
1022
+ const addToOffsetMap = (f: Feature, isoKey: string) => {
1023
+ if (!etcMap.has(isoKey)) {
1024
+ etcMap.set(isoKey, { type: "FeatureCollection", features: [] });
1025
+ }
1026
+ const collection = etcMap.get(isoKey);
1027
+ if (!collection) return;
1028
+ collection.features.push(f);
1029
+ totalFeaturesAdded++;
1030
+ };
1031
+
1032
+ // Option 1: "iana-only" - Group IANA timezones by offset (default, recommended)
1033
+ if (ETCGMT_OFFSET_SOURCE === "iana-only") {
1034
+ console.log(" Using IANA timezones grouped by offset...");
1035
+ for (const f of simplifiedIanaFc.features) {
1036
+ const tzid = (f.properties?.tzid as string) ?? "";
1037
+ if (!tzid) continue;
1038
+
1039
+ try {
1040
+ const isoKey = ianaToEtcForGeneration(tzid);
1041
+ addToOffsetMap(f, isoKey);
1042
+ } catch (e) {
1043
+ skippedEtcFeatures++;
1044
+ }
1045
+ }
1046
+ }
1047
+ // Option 2: "natural-earth" - Use Natural Earth data grouped by offset
1048
+ else if (ETCGMT_OFFSET_SOURCE === "natural-earth") {
1049
+ console.log(" Using Natural Earth data grouped by offset...");
1050
+ for (const f of processedEtcgmtFeatures) {
1051
+ const tzid = (f.properties?.tzid as string) ?? "";
1052
+ let isoKey: string | null = null;
1053
+
1054
+ // Attempt: first treat as IANA (some Natural Earth features use canonical names)
1055
+ try {
1056
+ isoKey = ianaToEtcForGeneration(tzid);
1057
+ } catch (e) {
1058
+ try {
1059
+ // Try parse as Etc/GMT or GMT labels
1060
+ isoKey = offsetKeyFromEtcForGeneration(tzid);
1061
+ } catch (e2) {
1062
+ // Unable to derive offset key for this ETC/GMT feature — log and skip.
1063
+ console.warn(
1064
+ "Skipping ETCGMT feature without offset mapping:",
1065
+ tzid,
1066
+ { properties: f.properties },
1067
+ );
1068
+ skippedEtcFeatures++;
1069
+ continue;
1070
+ }
1071
+ }
1072
+
1073
+ if (!isoKey) {
1074
+ console.warn("Skipping ETCGMT feature with empty isoKey:", tzid);
1075
+ skippedEtcFeatures++;
1076
+ continue;
1077
+ }
1078
+
1079
+ addToOffsetMap(f, isoKey);
1080
+ }
1081
+ }
1082
+ // Option 3: "merge" - Merge both Natural Earth and IANA data
1083
+ else if (ETCGMT_OFFSET_SOURCE === "merge") {
1084
+ console.log(" Merging Natural Earth + IANA data...");
1085
+
1086
+ // First add IANA timezones
1087
+ for (const f of simplifiedIanaFc.features) {
1088
+ const tzid = (f.properties?.tzid as string) ?? "";
1089
+ if (!tzid) continue;
1090
+
1091
+ try {
1092
+ const isoKey = ianaToEtcForGeneration(tzid);
1093
+ addToOffsetMap(f, isoKey);
1094
+ } catch (e) {
1095
+ skippedEtcFeatures++;
1096
+ }
1097
+ }
1098
+
1099
+ // Then add Natural Earth timezones (skipping duplicates by tzid)
1100
+ const addedTzids = new Set<string>();
1101
+ for (const [, fc] of etcMap.entries()) {
1102
+ for (const f of fc.features) {
1103
+ const tzid = f.properties?.tzid as string | undefined;
1104
+ if (tzid) addedTzids.add(tzid);
1105
+ }
1106
+ }
1107
+
1108
+ for (const f of processedEtcgmtFeatures) {
1109
+ const tzid = (f.properties?.tzid as string) ?? "";
1110
+ if (!tzid) continue;
1111
+
1112
+ // Do not skip Natural Earth features even if a tzid was already added from IANA.
1113
+ // Include Natural Earth polygons (often larger / oceanic) so merged ETCGMT
1114
+ // offset geometries contain ocean areas as expected.
1115
+
1116
+ let isoKey: string | null = null;
1117
+ try {
1118
+ isoKey = ianaToEtcForGeneration(tzid);
1119
+ } catch (e) {
1120
+ try {
1121
+ isoKey = offsetKeyFromEtcForGeneration(tzid);
1122
+ } catch (e2) {
1123
+ skippedEtcFeatures++;
1124
+ continue;
1125
+ }
1126
+ }
1127
+
1128
+ if (isoKey) {
1129
+ addToOffsetMap(f, isoKey);
1130
+ addedTzids.add(tzid);
1131
+ }
1132
+ }
1133
+ }
1134
+
1135
+ const etcObj: Record<string, FeatureCollection> = {};
1136
+ for (const [k, v] of etcMap.entries()) etcObj[k] = v;
1137
+
1138
+ resetGeneratedDir(ETCGMT_OUTPUT_DIR);
1139
+ const sortedOffsetKeys = Object.keys(etcObj).sort();
1140
+
1141
+ for (const offsetKey of sortedOffsetKeys) {
1142
+ const fileStem = offsetKeyToFileStem(offsetKey);
1143
+ const featureCollection = etcObj[offsetKey];
1144
+ const featurePath = path.join(ETCGMT_OUTPUT_DIR, `${fileStem}.json`);
1145
+ fs.writeFileSync(featurePath, JSON.stringify(featureCollection, null, 2));
1146
+ }
1147
+
1148
+ const etcGeomLoaderPath = path.join(ETCGMT_OUTPUT_DIR, "index.ts");
1149
+ fs.writeFileSync(
1150
+ etcGeomLoaderPath,
1151
+ buildEtcgmtLoaderModule(sortedOffsetKeys),
1152
+ );
1153
+
1154
+ fs.rmSync(path.join(TZ_OUTPUT_DIR, "etcgmt-offset-geometries.json"), {
1155
+ force: true,
1156
+ });
1157
+
1158
+ console.log(
1159
+ ` ✅ Generated: ${ETCGMT_OUTPUT_DIR} (${sortedOffsetKeys.length} offset buckets, ${totalFeaturesAdded} features)`,
1160
+ );
1161
+ if (skippedEtcFeatures > 0) {
1162
+ console.warn(
1163
+ ` ⚠️ Skipped ${skippedEtcFeatures} feature(s) that could not be mapped to an offset key.`,
1164
+ );
1165
+ }
1166
+
1167
+ // Build IANA -> offset mapping for all validated canonical regions
1168
+ const ianaToOffset: Record<string, string> = {};
1169
+ for (const tz of usedRegions) {
1170
+ try {
1171
+ ianaToOffset[tz] = ianaToEtcForGeneration(tz);
1172
+ } catch (e) {
1173
+ console.warn(
1174
+ ` ⚠️ Skipping IANA region during offset mapping (unresolvable): ${tz}`,
1175
+ e,
1176
+ );
1177
+ // Skip but continue building the mapping for other regions
1178
+ }
1179
+ }
1180
+ const ianaMapPath = path.join(TZ_OUTPUT_DIR, "etcgmt-iana-to-offset.json");
1181
+ fs.writeFileSync(ianaMapPath, JSON.stringify(ianaToOffset, null, 2));
1182
+ console.log(
1183
+ ` ✅ Generated: ${ianaMapPath} (${Object.keys(ianaToOffset).length} mappings)`,
1184
+ );
1185
+
1186
+ console.log("\n🎉 Globe data generation complete!");
1187
+ console.log("\nData Usage:");
1188
+ console.log(
1189
+ " - TZ_BOUNDARY_MODES.IANA: Uses src/data/iana-timezones/* (timezone-keyed IANA geometries loaded on demand)",
1190
+ );
1191
+ console.log(
1192
+ " - TZ_BOUNDARY_MODES.ETCGMT: Uses src/data/etcgmt-offset-geometries/* (offset-keyed ETC/GMT geometries loaded on demand)",
1193
+ );
1194
+ } catch (err) {
1195
+ console.error("❌ Error generating globe data:", err);
1196
+ processExit(1);
1197
+ }
1198
+ }
1199
+
1200
+ // If this script is executed directly, run the generator.
1201
+ // Robust entry detection when executed via `tsx` or node. If the process argv
1202
+ // includes the script filename, treat this as the main entry and run the
1203
+ // generator. This keeps `generateGlobeData` importable for programmatic use.
1204
+ const invokedAsScript = process.argv.some(
1205
+ (a) =>
1206
+ a.endsWith("update-globe-data.ts") || a.endsWith("update-globe-data.js"),
1207
+ );
1208
+ if (invokedAsScript) {
1209
+ generateGlobeData().catch((err) => {
1210
+ console.error("Unhandled error:", err);
1211
+ process.exit(1);
1212
+ });
1213
+ }