tz-at-point 1.0.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 +337 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +396 -0
- package/dist/geo.d.ts +7 -0
- package/dist/geo.js +19 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +33 -0
- package/dist/key.d.ts +15 -0
- package/dist/key.js +28 -0
- package/dist/lookup.d.ts +55 -0
- package/dist/lookup.js +103 -0
- package/dist/points.d.ts +8 -0
- package/dist/points.js +62 -0
- package/dist/radius.d.ts +12 -0
- package/dist/radius.js +27 -0
- package/dist/table.d.ts +38 -0
- package/dist/table.js +76 -0
- package/dist/text.d.ts +7 -0
- package/dist/text.js +16 -0
- package/llms.txt +18 -0
- package/package.json +90 -0
- package/skills/tz-at-point/SKILL.md +106 -0
- package/skills/tz-at-point/references/api.md +115 -0
- package/skills/tz-at-point/references/cli.md +108 -0
- package/skills/tz-at-point/references/setup.md +107 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { accessSync, closeSync, constants, existsSync, fchmodSync, fsyncSync, lstatSync, openSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { basename, dirname, join, resolve as resolvePath } from 'node:path';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { keyOf, parseKey } from './key.js';
|
|
7
|
+
import { pointsFromCsv, pointsFromJson } from './points.js';
|
|
8
|
+
import { resolve } from './radius.js';
|
|
9
|
+
import { formatTable, isRadius, isZone, MAX_RADIUS, RADIUS_STEP, readTable, tableGeoTz, tableMaxRadius } from './table.js';
|
|
10
|
+
import { printable } from './text.js';
|
|
11
|
+
const USAGE = `usage:
|
|
12
|
+
tz-at-point build <points.json|points.csv> -o <zones.json> [--check | --refresh] [--max-radius 250]
|
|
13
|
+
tz-at-point check <zones.json> [--raster]
|
|
14
|
+
tz-at-point --version`;
|
|
15
|
+
/** A mistake at the command line. Its message is tz-at-point's own multi-line text, so newlines survive printing. */
|
|
16
|
+
class UsageError extends Error {
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* parseArgs with its errors rewritten. Node's own messages explain positionals and `--x=-y` syntax at
|
|
20
|
+
* length, one of them with an unterminated quote, and they would be printed under tz-at-point's name.
|
|
21
|
+
*/
|
|
22
|
+
function parse(args, options) {
|
|
23
|
+
try {
|
|
24
|
+
return parseArgs({ args, allowPositionals: true, options });
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
const { code, message } = err;
|
|
28
|
+
// Node quotes the option as '-o, --out <value>' or '--frobnicate'. Name the long form; and the
|
|
29
|
+
// unknown-option text is the user's own, so it is escaped before it joins a multi-line message.
|
|
30
|
+
const quoted = /'([^']*)'/.exec(message)?.[1] ?? '';
|
|
31
|
+
const raw = /--[^\s,]+/.exec(quoted)?.[0] ?? /-[^\s,]+/.exec(quoted)?.[0] ?? 'an option';
|
|
32
|
+
// Some of Node's messages quote only the short alias; name the long option it stands for.
|
|
33
|
+
const long = raw.length === 2 ? Object.entries(options).find(([, o]) => o.short === raw[1])?.[0] : undefined;
|
|
34
|
+
const option = printable(long ? `--${long}` : raw);
|
|
35
|
+
if (code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION')
|
|
36
|
+
throw new UsageError(`unknown option ${option}\n${USAGE}`);
|
|
37
|
+
if (code === 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE') {
|
|
38
|
+
throw new UsageError(message.includes('does not take an argument') ? `${option} does not take a value` : `${option} needs a value`);
|
|
39
|
+
}
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const READ_FAILURES = {
|
|
44
|
+
ENOENT: 'no such file or directory', EISDIR: 'is a directory', ENOTDIR: 'not a directory', EACCES: 'permission denied', EPERM: 'permission denied',
|
|
45
|
+
ELOOP: 'symlink loop', ENAMETOOLONG: 'name too long',
|
|
46
|
+
};
|
|
47
|
+
const WRITE_FAILURES = { ...READ_FAILURES, ENOENT: 'no such directory', EACCES: 'not writable', EPERM: 'not writable' };
|
|
48
|
+
/** A filesystem failure as a sentence about the user's path, not an errno and a syscall. */
|
|
49
|
+
function described(err, path, reasons = READ_FAILURES) {
|
|
50
|
+
const reason = reasons[err.code ?? ''];
|
|
51
|
+
return reason ? new Error(`${path}: ${reason}`) : err;
|
|
52
|
+
}
|
|
53
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
54
|
+
const verb = (n, singular, plural_) => (n === 1 ? singular : plural_);
|
|
55
|
+
/** A path as it can be pasted into a POSIX shell. */
|
|
56
|
+
const shellQuote = (path) => (/^[\w./-]+$/.test(path) ? path : `'${path.replace(/'/g, `'\\''`)}'`);
|
|
57
|
+
/** Print lines made from input safely, at most 20, then how many were left out. */
|
|
58
|
+
function printCapped(lines, print = console.log) {
|
|
59
|
+
for (const line of lines.slice(0, 20))
|
|
60
|
+
print(printable(line));
|
|
61
|
+
if (lines.length > 20)
|
|
62
|
+
print(`...and ${lines.length - 20} more`);
|
|
63
|
+
}
|
|
64
|
+
/** The installed geo-tz version, recorded in the table so a boundary bump shows up in the diff. */
|
|
65
|
+
function geoTzVersion() {
|
|
66
|
+
try {
|
|
67
|
+
// geo-tz does not export ./package.json, so resolve the dataset entry and walk up out of dist/.
|
|
68
|
+
const entry = new URL(import.meta.resolve('geo-tz/all'));
|
|
69
|
+
const { name, version } = JSON.parse(readFileSync(new URL('../package.json', entry), 'utf8'));
|
|
70
|
+
return name === 'geo-tz' && typeof version === 'string' && /^[\w.+-]{1,32}$/.test(version) ? version : undefined;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function loadFind() {
|
|
77
|
+
// geo-tz/all, not the default export: the default dataset merges zones that have shared rules
|
|
78
|
+
// since 1970, and answers Tromsø as Europe/Berlin.
|
|
79
|
+
const geoTz = await import('geo-tz/all').catch((err) => {
|
|
80
|
+
if (['ERR_MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED'].includes(err?.code)) {
|
|
81
|
+
throw new Error('this command needs geo-tz 8 or later: npm install --save-dev geo-tz');
|
|
82
|
+
}
|
|
83
|
+
throw err;
|
|
84
|
+
});
|
|
85
|
+
return (lat, lng) => geoTz.find(lat, lng)[0];
|
|
86
|
+
}
|
|
87
|
+
/** A file's text, without a byte-order mark. */
|
|
88
|
+
function readText(file) {
|
|
89
|
+
try {
|
|
90
|
+
return readFileSync(file, 'utf8').replace(/^/, '');
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
throw described(err, file);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function readJson(file) {
|
|
97
|
+
const text = readText(file);
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(text);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Not the parser's message: it quotes the file, and the file may not be what the user meant to pass.
|
|
103
|
+
throw new Error(`${file}: not valid JSON`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function readPoints(file) {
|
|
107
|
+
const lower = file.toLowerCase();
|
|
108
|
+
let points;
|
|
109
|
+
if (lower.endsWith('.json'))
|
|
110
|
+
points = pointsFromJson(readJson(file));
|
|
111
|
+
else if (lower.endsWith('.csv'))
|
|
112
|
+
points = pointsFromCsv(readText(file));
|
|
113
|
+
else
|
|
114
|
+
throw new Error(`${file}: points must be a .json or .csv file`);
|
|
115
|
+
return points;
|
|
116
|
+
}
|
|
117
|
+
/** The real file behind `out` (following a symlink), after checking its directory is writable. */
|
|
118
|
+
function writableTarget(out) {
|
|
119
|
+
let target;
|
|
120
|
+
try {
|
|
121
|
+
// A symlink is followed even when its target does not exist yet, so the link survives the write.
|
|
122
|
+
const link = lstatSync(out, { throwIfNoEntry: false })?.isSymbolicLink() ? resolvePath(dirname(out), readlinkSync(out)) : out;
|
|
123
|
+
// statSync rather than existsSync: existsSync answers false to a symlink loop, and the write
|
|
124
|
+
// would then land on the loop itself.
|
|
125
|
+
target = statSync(link, { throwIfNoEntry: false }) ? realpathSync(link) : resolvePath(link);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
throw described(err, out);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
accessSync(dirname(target), constants.W_OK);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
throw described(err, dirname(target), WRITE_FAILURES);
|
|
135
|
+
}
|
|
136
|
+
return target;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Replace `target` in one rename: an unguessable temp file created exclusively (so nothing planted at
|
|
140
|
+
* that name is followed), synced, renamed over the target, then the directory synced. A crash or power
|
|
141
|
+
* loss leaves either the old table or the new one. The target's permissions are kept.
|
|
142
|
+
*/
|
|
143
|
+
function writeAtomically(target, text) {
|
|
144
|
+
const dir = dirname(target);
|
|
145
|
+
// Bounded so a long but legal target name cannot push the temp name past NAME_MAX.
|
|
146
|
+
const temp = join(dir, `.${basename(target).slice(0, 180)}.${randomUUID().slice(0, 8)}.tmp`);
|
|
147
|
+
const fd = openSync(temp, 'wx', 0o644);
|
|
148
|
+
try {
|
|
149
|
+
// Node's permission model disables both of these; the rename still makes the swap atomic.
|
|
150
|
+
if (existsSync(target))
|
|
151
|
+
tolerate(() => fchmodSync(fd, statSync(target).mode & 0o777));
|
|
152
|
+
writeFileSync(fd, text);
|
|
153
|
+
tolerate(() => fsyncSync(fd));
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
closeSync(fd);
|
|
157
|
+
rmSync(temp, { force: true });
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
closeSync(fd);
|
|
161
|
+
try {
|
|
162
|
+
renameSync(temp, target);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
rmSync(temp, { force: true });
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
tolerate(() => {
|
|
169
|
+
const dirFd = openSync(dir, 'r');
|
|
170
|
+
try {
|
|
171
|
+
fsyncSync(dirFd);
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
closeSync(dirFd);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
/** Durability extras that some environments forbid (Node's permission model, odd filesystems). */
|
|
179
|
+
function tolerate(action) {
|
|
180
|
+
try {
|
|
181
|
+
action();
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
const code = err.code;
|
|
185
|
+
if (code !== 'ERR_ACCESS_DENIED' && code !== 'EPERM' && code !== 'EINVAL' && code !== 'ENOTSUP')
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async function build(args) {
|
|
190
|
+
const { values, positionals } = parse(args, {
|
|
191
|
+
out: { type: 'string', short: 'o' },
|
|
192
|
+
check: { type: 'boolean' },
|
|
193
|
+
refresh: { type: 'boolean' },
|
|
194
|
+
'max-radius': { type: 'string', default: '250' },
|
|
195
|
+
help: { type: 'boolean', short: 'h' },
|
|
196
|
+
});
|
|
197
|
+
if (values.help) {
|
|
198
|
+
console.log(USAGE);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
const [input] = positionals;
|
|
202
|
+
const { out } = values;
|
|
203
|
+
if (!input || !out || positionals.length > 1)
|
|
204
|
+
throw new UsageError(USAGE);
|
|
205
|
+
if (values.check && values.refresh)
|
|
206
|
+
throw new Error('--check and --refresh cannot be combined');
|
|
207
|
+
const maxRadius = /^\d+$/.test(values['max-radius']) ? Number(values['max-radius']) : NaN;
|
|
208
|
+
if (!isRadius(maxRadius))
|
|
209
|
+
throw new Error(`--max-radius must be a multiple of ${RADIUS_STEP} from 0 to ${MAX_RADIUS}`);
|
|
210
|
+
const exists = existsSync(out);
|
|
211
|
+
const readCommitted = () => (existsSync(out) ? readTable(readJson(out), out) : []);
|
|
212
|
+
const table = new Map(readCommitted().map((e) => [e.key, [e.zone, e.radius]]));
|
|
213
|
+
const builtWith = exists ? tableMaxRadius(readJson(out)) : undefined;
|
|
214
|
+
// Radii mean nothing without the cap they were probed under, so a different --max-radius re-resolves.
|
|
215
|
+
const reResolve = values.refresh || (builtWith !== undefined && builtWith !== maxRadius);
|
|
216
|
+
const points = readPoints(input).map((p) => ({ ...p, key: keyOf(p.lat, p.lng) }));
|
|
217
|
+
const added = points.filter((p) => !table.has(p.key));
|
|
218
|
+
const todo = new Set(added.map((p) => p.key));
|
|
219
|
+
if (reResolve)
|
|
220
|
+
for (const key of table.keys())
|
|
221
|
+
todo.add(key);
|
|
222
|
+
if (values.check) {
|
|
223
|
+
if (exists && todo.size === 0) {
|
|
224
|
+
console.log(`up to date: ${plural(table.size, 'point')}`);
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
console.log(printable(exists ? `${plural(todo.size, 'point')} missing from ${out}:` : `${out} does not exist yet`));
|
|
228
|
+
printCapped([...todo].sort().map((key) => ` ${key}`));
|
|
229
|
+
const sameRadius = values['max-radius'] === '250' ? '' : ` --max-radius ${maxRadius}`;
|
|
230
|
+
console.log(printable(`run: npx tz-at-point build ${shellQuote(input)} -o ${shellQuote(out)}${sameRadius}`));
|
|
231
|
+
return 1;
|
|
232
|
+
}
|
|
233
|
+
const warnings = new Set();
|
|
234
|
+
// An empty table is a legitimate first commit, so it is written; but a header with no rows, or `[]`,
|
|
235
|
+
// is also what the wrong file looks like, and silence would send every lookup quietly to the raster.
|
|
236
|
+
// A table that already has entries keeps answering them, so there is nothing to warn about then.
|
|
237
|
+
if (points.length === 0 && table.size === 0)
|
|
238
|
+
warnings.add(`warning: ${input} has no points, so ${out} answers nothing`);
|
|
239
|
+
let geoTz;
|
|
240
|
+
if (todo.size > 0 || !exists) {
|
|
241
|
+
const target = writableTarget(out);
|
|
242
|
+
const before = new Map(table);
|
|
243
|
+
if (todo.size > 0) {
|
|
244
|
+
const find = await loadFind();
|
|
245
|
+
geoTz = geoTzVersion();
|
|
246
|
+
for (const key of todo) {
|
|
247
|
+
const [zone, radius] = resolve(find, ...parseKey(key), maxRadius);
|
|
248
|
+
if (!zone)
|
|
249
|
+
throw new Error(`no zone found for ${key}`);
|
|
250
|
+
// Checked before anything is written, not only when the table is read back afterwards.
|
|
251
|
+
if (!isZone(zone))
|
|
252
|
+
throw new Error(`${key}: geo-tz returned an invalid zone name`);
|
|
253
|
+
table.set(key, [zone, radius]);
|
|
254
|
+
}
|
|
255
|
+
// The table answers for the rounded key. Within a few metres of a border the point itself can be across it.
|
|
256
|
+
for (const p of added) {
|
|
257
|
+
const zone = find(p.lat, p.lng);
|
|
258
|
+
const [keyZone] = table.get(p.key);
|
|
259
|
+
if (zone !== keyZone)
|
|
260
|
+
warnings.add(`warning: ${p.lat},${p.lng} is in ${zone}, but its key ${p.key} is in ${keyZone}; lookups there answer ${keyZone}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Another build may have written between our read and this write: merge with whatever is on disk now,
|
|
264
|
+
// keeping our freshly resolved entries, and confirm the file we leave behind holds all of them.
|
|
265
|
+
for (let attempt = 1;; attempt++) {
|
|
266
|
+
for (const e of readCommitted())
|
|
267
|
+
if (!table.has(e.key))
|
|
268
|
+
table.set(e.key, [e.zone, e.radius]);
|
|
269
|
+
// Lineage describes the whole table, so only a run that resolved every entry may stamp it. A partial
|
|
270
|
+
// build keeps what is recorded and says so, rather than claiming entries it never touched are fresh.
|
|
271
|
+
const resolvedEverything = !exists || reResolve;
|
|
272
|
+
const recorded = exists ? tableGeoTz(readJson(out)) : undefined;
|
|
273
|
+
const lineage = resolvedEverything ? geoTz : recorded;
|
|
274
|
+
if (!resolvedEverything && recorded && geoTz && recorded !== geoTz) {
|
|
275
|
+
warnings.add(`warning: ${out} still records geo-tz ${recorded}; new points were resolved with ${geoTz}; run --refresh to re-resolve the rest`);
|
|
276
|
+
}
|
|
277
|
+
writeAtomically(target, formatTable(table, maxRadius, lineage));
|
|
278
|
+
const committed = new Map(readCommitted().map((e) => [e.key, `${e.zone},${e.radius}`]));
|
|
279
|
+
const lost = [...table].filter(([key, [zone, radius]]) => committed.get(key) !== `${zone},${radius}`);
|
|
280
|
+
if (lost.length === 0)
|
|
281
|
+
break;
|
|
282
|
+
if (attempt === 5)
|
|
283
|
+
throw new Error(`${out} kept changing underneath this build; run it again`);
|
|
284
|
+
}
|
|
285
|
+
const changed = [...before].filter(([key, [zone, radius]]) => table.get(key)[0] !== zone || table.get(key)[1] !== radius).length;
|
|
286
|
+
const detail = reResolve ? `, ${changed} changed` : '';
|
|
287
|
+
const what = reResolve && !values.refresh ? `re-resolved at --max-radius ${maxRadius}: ` : '';
|
|
288
|
+
console.log(printable(`${what}wrote ${out}: ${plural(table.size, 'point')} (${todo.size} resolved${detail})`));
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
console.log(`up to date: ${plural(table.size, 'point')}`);
|
|
292
|
+
}
|
|
293
|
+
// A radius-0 entry still answers its whole ~11m key cell, including any part of it across the border.
|
|
294
|
+
// A radius of 0 that came from --max-radius 0 says nothing about borders, so it earns no warning.
|
|
295
|
+
for (const p of maxRadius === 0 ? [] : points) {
|
|
296
|
+
const [zone, radius] = table.get(p.key);
|
|
297
|
+
if (radius === 0)
|
|
298
|
+
warnings.add(`warning: ${p.key} is within 10m of another zone; lookups that round to it answer ${zone}, even from across the border`);
|
|
299
|
+
}
|
|
300
|
+
printCapped([...warnings], console.error);
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
303
|
+
async function check(args) {
|
|
304
|
+
const { values, positionals } = parse(args, { raster: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } });
|
|
305
|
+
if (values.help) {
|
|
306
|
+
console.log(USAGE);
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
309
|
+
if (positionals.length !== 1)
|
|
310
|
+
throw new UsageError(USAGE);
|
|
311
|
+
const [file] = positionals;
|
|
312
|
+
const raw = readJson(file);
|
|
313
|
+
const entries = readTable(raw, file);
|
|
314
|
+
const find = await loadFind();
|
|
315
|
+
const builtWith = tableGeoTz(raw);
|
|
316
|
+
const installed = geoTzVersion();
|
|
317
|
+
if (builtWith && installed && builtWith !== installed) {
|
|
318
|
+
console.log(`built with geo-tz ${builtWith}, checked against ${installed}`);
|
|
319
|
+
}
|
|
320
|
+
else if (builtWith) {
|
|
321
|
+
console.log(`built with geo-tz ${builtWith}`);
|
|
322
|
+
}
|
|
323
|
+
const unknownZones = [];
|
|
324
|
+
for (const zone of new Set(entries.map((e) => e.zone))) {
|
|
325
|
+
try {
|
|
326
|
+
new Intl.DateTimeFormat('en', { timeZone: zone });
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
unknownZones.push(`FAIL ${zone}: not a zone this runtime's Intl accepts`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const stale = [];
|
|
333
|
+
for (const { key, lat, lng, zone, radius } of entries) {
|
|
334
|
+
const [truth, safe] = resolve(find, lat, lng, radius);
|
|
335
|
+
if (truth !== zone)
|
|
336
|
+
stale.push(`FAIL ${key}: table says ${zone}, polygons say ${truth}`);
|
|
337
|
+
else if (safe < radius)
|
|
338
|
+
stale.push(`FAIL ${key}: radius ${radius}m reaches another zone`);
|
|
339
|
+
}
|
|
340
|
+
// What the raster alone would answer for these same points: the case for keeping the table at all.
|
|
341
|
+
if (values.raster) {
|
|
342
|
+
const { default: tzlookup } = await import('@photostructure/tz-lookup');
|
|
343
|
+
// Sampled across the year ahead, so a zone that diverges only for part of it (Ramadan in Casablanca,
|
|
344
|
+
// a southern-hemisphere summer) is not reported as harmless. Unknown zones count as different.
|
|
345
|
+
const quarters = [0, 3, 6, 9].map((months) => { const d = new Date(); d.setMonth(d.getMonth() + months); return d; });
|
|
346
|
+
const offsets = (zone) => {
|
|
347
|
+
try {
|
|
348
|
+
return quarters.map((d) => new Intl.DateTimeFormat('en', { timeZone: zone, timeZoneName: 'longOffset' }).format(d)).join();
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return `unknown:${zone}`;
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
const differing = entries.map((e) => ({ e, raster: tzlookup(e.lat, e.lng) })).filter(({ e, raster }) => raster !== e.zone);
|
|
355
|
+
const shifted = differing.filter(({ e, raster }) => offsets(raster) !== offsets(e.zone));
|
|
356
|
+
console.log(`raster: ${differing.length} of ${plural(entries.length, 'point')} ${verb(differing.length, 'disagrees', 'disagree')} with the table, ${shifted.length} by a different UTC offset`);
|
|
357
|
+
printCapped(differing.map(({ e, raster }) => ` ${e.key}: raster says ${raster}, table says ${e.zone}`));
|
|
358
|
+
}
|
|
359
|
+
if (unknownZones.length === 0 && stale.length === 0) {
|
|
360
|
+
console.log(`ok: ${plural(entries.length, 'point')} ${verb(entries.length, 'matches', 'match')} the polygons`);
|
|
361
|
+
return 0;
|
|
362
|
+
}
|
|
363
|
+
printCapped([...unknownZones, ...stale]);
|
|
364
|
+
if (unknownZones.length)
|
|
365
|
+
console.log("fix: update Node; its timezone data doesn't know these zones");
|
|
366
|
+
if (stale.length)
|
|
367
|
+
console.log('fix: rebuild the table with --refresh');
|
|
368
|
+
return 1;
|
|
369
|
+
}
|
|
370
|
+
/** This package's own version, from the package.json one directory up from dist/ and from src/ alike. */
|
|
371
|
+
const ownVersion = () => JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
372
|
+
async function main([command, ...args]) {
|
|
373
|
+
if (command === '--help' || command === '-h') {
|
|
374
|
+
console.log(USAGE);
|
|
375
|
+
return 0;
|
|
376
|
+
}
|
|
377
|
+
if (command === '--version') {
|
|
378
|
+
console.log(ownVersion());
|
|
379
|
+
return 0;
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
if (command === 'build')
|
|
383
|
+
return await build(args);
|
|
384
|
+
if (command === 'check')
|
|
385
|
+
return await check(args);
|
|
386
|
+
throw new UsageError(USAGE);
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
// A usage error is several lines of tz-at-point's own text, so its newlines are kept. Every other
|
|
390
|
+
// message can carry a path or file content, where a newline would forge a log line.
|
|
391
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
392
|
+
console.error(`tz-at-point: ${printable(message, err instanceof UsageError)}`);
|
|
393
|
+
return 2;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
process.exitCode = await main(process.argv.slice(2));
|
package/dist/geo.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const RAD: number;
|
|
2
|
+
/** Remainder with the sign of the divisor, so negative values wrap around. */
|
|
3
|
+
export declare const mod: (a: number, n: number) => number;
|
|
4
|
+
/** Great-circle distance in metres. */
|
|
5
|
+
export declare function metres(aLat: number, aLng: number, bLat: number, bLng: number): number;
|
|
6
|
+
/** The point `distance` metres from `lat,lng` along `bearing` degrees, longitude wrapped to [-180, 180). */
|
|
7
|
+
export declare function destination(lat: number, lng: number, distance: number, bearing: number): [number, number];
|
package/dist/geo.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const R = 6_371_008.8;
|
|
2
|
+
export const RAD = Math.PI / 180;
|
|
3
|
+
/** Remainder with the sign of the divisor, so negative values wrap around. */
|
|
4
|
+
export const mod = (a, n) => ((a % n) + n) % n;
|
|
5
|
+
/** Great-circle distance in metres. */
|
|
6
|
+
export function metres(aLat, aLng, bLat, bLng) {
|
|
7
|
+
const h = Math.sin(((bLat - aLat) * RAD) / 2) ** 2
|
|
8
|
+
+ Math.cos(aLat * RAD) * Math.cos(bLat * RAD) * Math.sin(((bLng - aLng) * RAD) / 2) ** 2;
|
|
9
|
+
return 2 * R * Math.asin(Math.sqrt(Math.min(1, h)));
|
|
10
|
+
}
|
|
11
|
+
/** The point `distance` metres from `lat,lng` along `bearing` degrees, longitude wrapped to [-180, 180). */
|
|
12
|
+
export function destination(lat, lng, distance, bearing) {
|
|
13
|
+
const d = distance / R;
|
|
14
|
+
const b = bearing * RAD;
|
|
15
|
+
const p1 = lat * RAD;
|
|
16
|
+
const p2 = Math.asin(Math.sin(p1) * Math.cos(d) + Math.cos(p1) * Math.sin(d) * Math.cos(b));
|
|
17
|
+
const l2 = lng * RAD + Math.atan2(Math.sin(b) * Math.sin(d) * Math.cos(p1), Math.cos(d) - Math.sin(p1) * Math.sin(p2));
|
|
18
|
+
return [p2 / RAD, mod(l2 / RAD + 180, 360) - 180];
|
|
19
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Lookup, Options } from './lookup.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Build a lookup from a table made by `npx tz-at-point build points.json -o zones.json`.
|
|
4
|
+
*
|
|
5
|
+
* Call it once, at module scope, with the table imported as JSON so your bundler embeds it; the lookup
|
|
6
|
+
* then reads no files, which is what makes it safe in serverless functions. Points the table doesn't
|
|
7
|
+
* cover are answered by a bundled raster unless you pass `fallback`. The table is validated here (it is
|
|
8
|
+
* typed `unknown` because JSON imports are typed loosely): a malformed table throws a TypeError at
|
|
9
|
+
* startup. The returned function never throws.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* import { createLookup } from 'tz-at-point';
|
|
13
|
+
* import table from './zones.json' with { type: 'json' };
|
|
14
|
+
*
|
|
15
|
+
* const zoneAt = createLookup(table);
|
|
16
|
+
*
|
|
17
|
+
* const { zone } = zoneAt(51.5561, -0.2794); // 'Europe/London'
|
|
18
|
+
* const local = zone && new Intl.DateTimeFormat('en-GB', { timeZone: zone, timeStyle: 'short' }).format(new Date('2026-11-14T19:45:00Z')); // '19:45'
|
|
19
|
+
*/
|
|
20
|
+
export declare const createLookup: (table: unknown, options?: Options | null) => Lookup;
|
|
21
|
+
export type { Lookup, Options, Result, Source } from './lookup.ts';
|
|
22
|
+
export { pointKey } from './key.ts';
|
|
23
|
+
export type { Table } from './table.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tz-at-point: the IANA timezone at a coordinate, exact at the points you care about, with no file reads at
|
|
3
|
+
* runtime.
|
|
4
|
+
*
|
|
5
|
+
* Workflow: list your points, run `npx tz-at-point build points.json -o zones.json` (needs geo-tz as a dev
|
|
6
|
+
* dependency), commit zones.json, import it as JSON and call `createLookup(table)` once.
|
|
7
|
+
*
|
|
8
|
+
* Coding agents: the full guide is in this package at `skills/tz-at-point/SKILL.md`.
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
*/
|
|
12
|
+
import tzlookup from '@photostructure/tz-lookup';
|
|
13
|
+
import { createLookup as fromTable } from './lookup.js';
|
|
14
|
+
/**
|
|
15
|
+
* Build a lookup from a table made by `npx tz-at-point build points.json -o zones.json`.
|
|
16
|
+
*
|
|
17
|
+
* Call it once, at module scope, with the table imported as JSON so your bundler embeds it; the lookup
|
|
18
|
+
* then reads no files, which is what makes it safe in serverless functions. Points the table doesn't
|
|
19
|
+
* cover are answered by a bundled raster unless you pass `fallback`. The table is validated here (it is
|
|
20
|
+
* typed `unknown` because JSON imports are typed loosely): a malformed table throws a TypeError at
|
|
21
|
+
* startup. The returned function never throws.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* import { createLookup } from 'tz-at-point';
|
|
25
|
+
* import table from './zones.json' with { type: 'json' };
|
|
26
|
+
*
|
|
27
|
+
* const zoneAt = createLookup(table);
|
|
28
|
+
*
|
|
29
|
+
* const { zone } = zoneAt(51.5561, -0.2794); // 'Europe/London'
|
|
30
|
+
* const local = zone && new Intl.DateTimeFormat('en-GB', { timeZone: zone, timeStyle: 'short' }).format(new Date('2026-11-14T19:45:00Z')); // '19:45'
|
|
31
|
+
*/
|
|
32
|
+
export const createLookup = (table, options) => fromTable(table, { fallback: options?.fallback === undefined ? tzlookup : options.fallback });
|
|
33
|
+
export { pointKey } from './key.js';
|
package/dist/key.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** `[lat, lng]` if both are finite numbers on the globe, otherwise null. */
|
|
2
|
+
export declare function latLng(lat: unknown, lng: unknown): [number, number] | null;
|
|
3
|
+
/** The table key for a valid coordinate: rounded to 4 decimals (~11m), with -180 and 180 as one meridian. */
|
|
4
|
+
export declare function keyOf(lat: number, lng: number): string;
|
|
5
|
+
/** The coordinate a key was made from (NaN parts if it is not a key). */
|
|
6
|
+
export declare const parseKey: (key: string) => [number, number];
|
|
7
|
+
/**
|
|
8
|
+
* The table key for a coordinate: latitude and longitude rounded to 4 decimals, or `null` if either is
|
|
9
|
+
* not a finite number in range. Use it to check whether a point is already in a table.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* pointKey(51.55614, -0.27936); // '51.5561,-0.2794'
|
|
13
|
+
* pointKey('51.5', 0); // null
|
|
14
|
+
*/
|
|
15
|
+
export declare function pointKey(lat: unknown, lng: unknown): string | null;
|
package/dist/key.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** `[lat, lng]` if both are finite numbers on the globe, otherwise null. */
|
|
2
|
+
export function latLng(lat, lng) {
|
|
3
|
+
return typeof lat === 'number' && typeof lng === 'number' && Math.abs(lat) <= 90 && Math.abs(lng) <= 180 ? [lat, lng] : null;
|
|
4
|
+
}
|
|
5
|
+
const fixed = (v) => {
|
|
6
|
+
const s = v.toFixed(4);
|
|
7
|
+
// Rounding, not the input, produces negative zero: -0.000004 must key the same as 0.000004.
|
|
8
|
+
return s === '-0.0000' ? '0.0000' : s;
|
|
9
|
+
};
|
|
10
|
+
/** The table key for a valid coordinate: rounded to 4 decimals (~11m), with -180 and 180 as one meridian. */
|
|
11
|
+
export function keyOf(lat, lng) {
|
|
12
|
+
const lngKey = fixed(lng);
|
|
13
|
+
return `${fixed(lat)},${lngKey === '-180.0000' ? '180.0000' : lngKey}`;
|
|
14
|
+
}
|
|
15
|
+
/** The coordinate a key was made from (NaN parts if it is not a key). */
|
|
16
|
+
export const parseKey = (key) => key.split(',').map(Number);
|
|
17
|
+
/**
|
|
18
|
+
* The table key for a coordinate: latitude and longitude rounded to 4 decimals, or `null` if either is
|
|
19
|
+
* not a finite number in range. Use it to check whether a point is already in a table.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* pointKey(51.55614, -0.27936); // '51.5561,-0.2794'
|
|
23
|
+
* pointKey('51.5', 0); // null
|
|
24
|
+
*/
|
|
25
|
+
export function pointKey(lat, lng) {
|
|
26
|
+
const point = latLng(lat, lng);
|
|
27
|
+
return point && keyOf(...point);
|
|
28
|
+
}
|
package/dist/lookup.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type Entry } from './table.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Where an answer came from.
|
|
4
|
+
* - `table`: the coordinate rounds to a table key (4 decimals, a cell about 11m across).
|
|
5
|
+
* - `table-near`: within the safe radius of the nearest covering table entry. A normal, correct answer.
|
|
6
|
+
* - `raster`: not covered by the table, so the fallback answered. Approximate near borders.
|
|
7
|
+
*/
|
|
8
|
+
export type Source = 'table' | 'table-near' | 'raster';
|
|
9
|
+
/**
|
|
10
|
+
* A lookup's answer. `zone` is an IANA name such as `Europe/London`; it is `null` only when the input is
|
|
11
|
+
* not a coordinate, or no fallback is configured (or it had no answer) for a point outside the table.
|
|
12
|
+
*/
|
|
13
|
+
export type Result = {
|
|
14
|
+
zone: string;
|
|
15
|
+
source: Source;
|
|
16
|
+
} | {
|
|
17
|
+
zone: null;
|
|
18
|
+
source: null;
|
|
19
|
+
};
|
|
20
|
+
/** Answers the IANA zone at a coordinate. Never throws, whatever it is passed. */
|
|
21
|
+
export type Lookup = (lat: unknown, lng: unknown) => Result;
|
|
22
|
+
export interface Options {
|
|
23
|
+
/**
|
|
24
|
+
* Answers points the table does not cover. Imported from `tz-at-point`, this defaults to
|
|
25
|
+
* `@photostructure/tz-lookup` (a 73KB raster, no file reads); imported from `tz-at-point/core`, it defaults
|
|
26
|
+
* to nothing, so the raster never enters your bundle. Answers that are not zone names are ignored.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* const tableOnly = createLookup(table, { fallback: null });
|
|
30
|
+
*/
|
|
31
|
+
fallback?: ((lat: number, lng: number) => string | null | undefined) | null;
|
|
32
|
+
}
|
|
33
|
+
/** Every cell an entry's radius reaches. Exported for tests. */
|
|
34
|
+
export declare function cellsReached({ lat, lng, radius }: Entry): Generator<number, void, unknown>;
|
|
35
|
+
/**
|
|
36
|
+
* Build a lookup that answers only from the table (`tz-at-point/core`). `tz-at-point` re-exports this with the
|
|
37
|
+
* raster fallback applied; import from here when you never want that 73KB in your bundle.
|
|
38
|
+
*
|
|
39
|
+
* Build a lookup from a table made by `npx tz-at-point build points.json -o zones.json`.
|
|
40
|
+
*
|
|
41
|
+
* Call it once, at module scope, with the table imported as JSON so your bundler embeds it; the lookup
|
|
42
|
+
* then reads no files, which is what makes it safe in serverless functions. The table is validated here
|
|
43
|
+
* (it is typed `unknown` because JSON imports are typed loosely): a malformed table throws a TypeError at
|
|
44
|
+
* startup. The returned function never throws.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* import { createLookup } from 'tz-at-point';
|
|
48
|
+
* import table from './zones.json' with { type: 'json' };
|
|
49
|
+
*
|
|
50
|
+
* const zoneAt = createLookup(table);
|
|
51
|
+
*
|
|
52
|
+
* const { zone } = zoneAt(51.5561, -0.2794); // 'Europe/London'
|
|
53
|
+
* const local = zone && new Intl.DateTimeFormat('en-GB', { timeZone: zone, timeStyle: 'short' }).format(new Date('2026-11-14T19:45:00Z')); // '19:45'
|
|
54
|
+
*/
|
|
55
|
+
export declare function createLookup(table: unknown, options?: Options | null): Lookup;
|