@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.
- package/LICENSE +21 -0
- package/README.md +416 -0
- package/package.json +92 -0
- package/scripts/test-iana-to-etc.ts +52 -0
- package/scripts/update-globe-data.ts +1213 -0
- package/scripts/validate-geometry.ts +246 -0
|
@@ -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
|
+
}
|