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/lookup.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { metres, mod, RAD } from './geo.js';
|
|
2
|
+
import { keyOf, latLng } from './key.js';
|
|
3
|
+
import { isZone, readTable } from './table.js';
|
|
4
|
+
/**
|
|
5
|
+
* Near search reads one grid cell. Rows are 0.01° of latitude. Each row has fewer columns the nearer it
|
|
6
|
+
* is to a pole, so every cell is at least ~1.1km wide, and an entry (radius at most 1km) reaches a
|
|
7
|
+
* handful of cells anywhere on Earth. A lookup's cost depends on how many entries overlap that spot.
|
|
8
|
+
*/
|
|
9
|
+
const CELL = 0.01;
|
|
10
|
+
const EQUATOR_COLUMNS = 36_000;
|
|
11
|
+
/** Metres per degree of latitude, rounded down so an entry's span errs wide. */
|
|
12
|
+
const METRES_PER_DEGREE = 110_000;
|
|
13
|
+
/** Columns in a row, sized by the cosine of its poleward edge. */
|
|
14
|
+
const columnsIn = (row) => Math.max(1, Math.floor(EQUATOR_COLUMNS * Math.cos(Math.min(90, Math.max(Math.abs(row), Math.abs(row + 1)) * CELL) * RAD)));
|
|
15
|
+
const columnOf = (lng, columns) => Math.floor(((lng + 180) / 360) * columns);
|
|
16
|
+
const cellId = (row, column, columns) => row * EQUATOR_COLUMNS + mod(column, columns);
|
|
17
|
+
/** Every cell an entry's radius reaches. Exported for tests. */
|
|
18
|
+
export function* cellsReached({ lat, lng, radius }) {
|
|
19
|
+
const dLat = radius / METRES_PER_DEGREE;
|
|
20
|
+
const dLng = dLat / Math.max(Math.cos((Math.abs(lat) + dLat) * RAD), 1e-9);
|
|
21
|
+
for (let row = Math.floor((lat - dLat) / CELL); row <= Math.floor((lat + dLat) / CELL); row++) {
|
|
22
|
+
const columns = columnsIn(row);
|
|
23
|
+
const west = columnOf(lng - dLng, columns);
|
|
24
|
+
const east = Math.min(columnOf(lng + dLng, columns), west + columns - 1);
|
|
25
|
+
for (let column = west; column <= east; column++)
|
|
26
|
+
yield cellId(row, column, columns);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build a lookup that answers only from the table (`tz-at-point/core`). `tz-at-point` re-exports this with the
|
|
31
|
+
* raster fallback applied; import from here when you never want that 73KB in your bundle.
|
|
32
|
+
*
|
|
33
|
+
* Build a lookup from a table made by `npx tz-at-point build points.json -o zones.json`.
|
|
34
|
+
*
|
|
35
|
+
* Call it once, at module scope, with the table imported as JSON so your bundler embeds it; the lookup
|
|
36
|
+
* then reads no files, which is what makes it safe in serverless functions. The table is validated here
|
|
37
|
+
* (it is typed `unknown` because JSON imports are typed loosely): a malformed table throws a TypeError at
|
|
38
|
+
* startup. The returned function never throws.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* import { createLookup } from 'tz-at-point';
|
|
42
|
+
* import table from './zones.json' with { type: 'json' };
|
|
43
|
+
*
|
|
44
|
+
* const zoneAt = createLookup(table);
|
|
45
|
+
*
|
|
46
|
+
* const { zone } = zoneAt(51.5561, -0.2794); // 'Europe/London'
|
|
47
|
+
* const local = zone && new Intl.DateTimeFormat('en-GB', { timeZone: zone, timeStyle: 'short' }).format(new Date('2026-11-14T19:45:00Z')); // '19:45'
|
|
48
|
+
*/
|
|
49
|
+
export function createLookup(table, options) {
|
|
50
|
+
const fallback = options?.fallback ?? null;
|
|
51
|
+
const exact = new Map();
|
|
52
|
+
const cells = new Map();
|
|
53
|
+
for (const entry of readTable(table)) {
|
|
54
|
+
exact.set(entry.key, entry.zone);
|
|
55
|
+
if (entry.radius === 0)
|
|
56
|
+
continue;
|
|
57
|
+
for (const id of cellsReached(entry)) {
|
|
58
|
+
const list = cells.get(id);
|
|
59
|
+
if (list)
|
|
60
|
+
list.push(entry);
|
|
61
|
+
else
|
|
62
|
+
cells.set(id, [entry]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return (lat, lng) => {
|
|
66
|
+
const point = latLng(lat, lng);
|
|
67
|
+
if (!point)
|
|
68
|
+
return { zone: null, source: null };
|
|
69
|
+
const [y, x] = point;
|
|
70
|
+
const hit = exact.get(keyOf(y, x));
|
|
71
|
+
if (hit !== undefined)
|
|
72
|
+
return { zone: hit, source: 'table' };
|
|
73
|
+
const row = Math.floor(y / CELL);
|
|
74
|
+
const columns = columnsIn(row);
|
|
75
|
+
const near = nearest(cells.get(cellId(row, columnOf(x, columns), columns)), y, x);
|
|
76
|
+
if (near !== null)
|
|
77
|
+
return { zone: near, source: 'table-near' };
|
|
78
|
+
if (fallback) {
|
|
79
|
+
try {
|
|
80
|
+
const zone = fallback(y, x);
|
|
81
|
+
if (isZone(zone))
|
|
82
|
+
return { zone, source: 'raster' };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// A fallback failure is an unresolved point, not a crash.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { zone: null, source: null };
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** Zone of the nearest entry whose own radius covers the point. */
|
|
92
|
+
function nearest(entries, lat, lng) {
|
|
93
|
+
let zone = null;
|
|
94
|
+
let best = Infinity;
|
|
95
|
+
for (const e of entries ?? []) {
|
|
96
|
+
const d = metres(lat, lng, e.lat, e.lng);
|
|
97
|
+
if (d <= e.radius && d < best) {
|
|
98
|
+
best = d;
|
|
99
|
+
zone = e.zone;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return zone;
|
|
103
|
+
}
|
package/dist/points.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface Point {
|
|
2
|
+
lat: number;
|
|
3
|
+
lng: number;
|
|
4
|
+
}
|
|
5
|
+
/** Points from parsed JSON: an array of `[lat, lng]` or `{ lat, lng }`. */
|
|
6
|
+
export declare function pointsFromJson(rows: unknown): Point[];
|
|
7
|
+
/** Points from CSV text with `lat` and `lng` header columns, in any order. */
|
|
8
|
+
export declare function pointsFromCsv(text: string): Point[];
|
package/dist/points.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { latLng } from './key.js';
|
|
2
|
+
/** A plain decimal: no hex, exponents or blanks, which Number() would otherwise accept. */
|
|
3
|
+
const DECIMAL = /^\s*-?\d+(\.\d+)?\s*$/;
|
|
4
|
+
/** Points from parsed JSON: an array of `[lat, lng]` or `{ lat, lng }`. */
|
|
5
|
+
export function pointsFromJson(rows) {
|
|
6
|
+
if (!Array.isArray(rows))
|
|
7
|
+
throw new Error('JSON points must be an array');
|
|
8
|
+
return rows.map((row, i) => {
|
|
9
|
+
const point = Array.isArray(row) ? row.length === 2 && latLng(row[0], row[1]) : latLng(row?.lat, row?.lng);
|
|
10
|
+
if (!point)
|
|
11
|
+
throw new Error(`row ${i + 1}: expected [lat, lng] or { lat, lng } with numbers in range`);
|
|
12
|
+
return { lat: point[0], lng: point[1] };
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** Points from CSV text with `lat` and `lng` header columns, in any order. */
|
|
16
|
+
export function pointsFromCsv(text) {
|
|
17
|
+
const [header, ...lines] = text.split(/\r?\n/);
|
|
18
|
+
const columns = (splitCsv(header) ?? []).map((h) => h.trim().toLowerCase());
|
|
19
|
+
const latAt = columns.indexOf('lat');
|
|
20
|
+
const lngAt = columns.indexOf('lng');
|
|
21
|
+
if (latAt < 0 || lngAt < 0)
|
|
22
|
+
throw new Error('CSV header must have lat and lng columns');
|
|
23
|
+
const points = [];
|
|
24
|
+
for (const [i, line] of lines.entries()) {
|
|
25
|
+
if (line.trim() === '')
|
|
26
|
+
continue;
|
|
27
|
+
const cells = splitCsv(line);
|
|
28
|
+
if (!cells)
|
|
29
|
+
throw new Error(`line ${i + 2}: unterminated quote (quoted line breaks are not supported)`);
|
|
30
|
+
const [lat, lng] = [cells[latAt], cells[lngAt]].map((c) => (DECIMAL.test(c) ? Number(c) : NaN));
|
|
31
|
+
const point = latLng(lat, lng);
|
|
32
|
+
if (!point)
|
|
33
|
+
throw new Error(`line ${i + 2}: lat and lng must be decimal numbers in range`);
|
|
34
|
+
points.push({ lat: point[0], lng: point[1] });
|
|
35
|
+
}
|
|
36
|
+
return points;
|
|
37
|
+
}
|
|
38
|
+
/** One CSV record: comma-separated, fields optionally in double quotes, "" for a literal quote. Null if a quote is left open. */
|
|
39
|
+
function splitCsv(line) {
|
|
40
|
+
const cells = [];
|
|
41
|
+
let cell = '';
|
|
42
|
+
let quoted = false;
|
|
43
|
+
for (let i = 0; i < line.length; i++) {
|
|
44
|
+
const c = line[i];
|
|
45
|
+
if (quoted && c === '"' && line[i + 1] === '"') {
|
|
46
|
+
cell += '"';
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
else if (c === '"') {
|
|
50
|
+
quoted = !quoted;
|
|
51
|
+
}
|
|
52
|
+
else if (c === ',' && !quoted) {
|
|
53
|
+
cells.push(cell);
|
|
54
|
+
cell = '';
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
cell += c;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
cells.push(cell);
|
|
61
|
+
return quoted ? null : cells;
|
|
62
|
+
}
|
package/dist/radius.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type Find = (lat: number, lng: number) => string | undefined;
|
|
2
|
+
/**
|
|
3
|
+
* The widest radius (a multiple of RADIUS_STEP, at most `max`) around a point that the probes vouch for.
|
|
4
|
+
*
|
|
5
|
+
* Probes fill the disc on a ~10m lattice, ring by ring. A ring only samples its circle at intervals, so a
|
|
6
|
+
* narrow lobe of another zone can cross it between two probes; the radius therefore stops a whole ring
|
|
7
|
+
* short of the frontier, and the ring beyond `max` is probed as well. Even so this is sampling, not proof:
|
|
8
|
+
* a piece of another zone smaller than the lattice's ~7m cover radius can sit inside the radius unseen.
|
|
9
|
+
*/
|
|
10
|
+
export declare function safeRadius(find: Find, lat: number, lng: number, zone: string, max: number): number;
|
|
11
|
+
/** A point's zone and safe radius: what build stores, and what check expects to find again. */
|
|
12
|
+
export declare function resolve(find: Find, lat: number, lng: number, max: number): [zone: string | undefined, radius: number];
|
package/dist/radius.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { destination } from './geo.js';
|
|
2
|
+
import { RADIUS_STEP } from './table.js';
|
|
3
|
+
/**
|
|
4
|
+
* The widest radius (a multiple of RADIUS_STEP, at most `max`) around a point that the probes vouch for.
|
|
5
|
+
*
|
|
6
|
+
* Probes fill the disc on a ~10m lattice, ring by ring. A ring only samples its circle at intervals, so a
|
|
7
|
+
* narrow lobe of another zone can cross it between two probes; the radius therefore stops a whole ring
|
|
8
|
+
* short of the frontier, and the ring beyond `max` is probed as well. Even so this is sampling, not proof:
|
|
9
|
+
* a piece of another zone smaller than the lattice's ~7m cover radius can sit inside the radius unseen.
|
|
10
|
+
*/
|
|
11
|
+
export function safeRadius(find, lat, lng, zone, max) {
|
|
12
|
+
if (max < RADIUS_STEP)
|
|
13
|
+
return 0;
|
|
14
|
+
for (let ring = RADIUS_STEP; ring <= max + RADIUS_STEP; ring += RADIUS_STEP) {
|
|
15
|
+
const bearings = Math.ceil((2 * Math.PI * ring) / RADIUS_STEP);
|
|
16
|
+
for (let i = 0; i < bearings; i++) {
|
|
17
|
+
if (find(...destination(lat, lng, ring, (360 * i) / bearings)) !== zone)
|
|
18
|
+
return Math.max(0, ring - 2 * RADIUS_STEP);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return Math.floor(max / RADIUS_STEP) * RADIUS_STEP;
|
|
22
|
+
}
|
|
23
|
+
/** A point's zone and safe radius: what build stores, and what check expects to find again. */
|
|
24
|
+
export function resolve(find, lat, lng, max) {
|
|
25
|
+
const zone = find(lat, lng);
|
|
26
|
+
return [zone, zone === undefined ? 0 : safeRadius(find, lat, lng, zone, max)];
|
|
27
|
+
}
|
package/dist/table.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The table `tz-at-point build` writes: point key → [IANA zone, safe radius in metres]. Commit it and don't
|
|
3
|
+
* edit it by hand; `tz-at-point build` adds points and `tz-at-point build --refresh` re-resolves them.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* { "v": 1, "points": { "51.5561,-0.2794": ["Europe/London", 250], "51.4394,4.9275": ["Europe/Amsterdam", 0] } }
|
|
7
|
+
*/
|
|
8
|
+
export interface Table {
|
|
9
|
+
v: 1;
|
|
10
|
+
/** The OpenStreetMap/ODbL credit, written into every generated table so the file explains itself. */
|
|
11
|
+
attribution?: string;
|
|
12
|
+
/** The `--max-radius` the table was built with, so a later build can tell its radii apart from probed ones. */
|
|
13
|
+
maxRadius?: number;
|
|
14
|
+
/** The geo-tz version whose boundaries produced these zones, so a data bump is visible in the diff. */
|
|
15
|
+
geoTz?: string;
|
|
16
|
+
points: Record<string, [zone: string, radius: number]>;
|
|
17
|
+
}
|
|
18
|
+
export interface Entry {
|
|
19
|
+
key: string;
|
|
20
|
+
lat: number;
|
|
21
|
+
lng: number;
|
|
22
|
+
zone: string;
|
|
23
|
+
radius: number;
|
|
24
|
+
}
|
|
25
|
+
/** Radii are multiples of the probe spacing, up to a kilometre. */
|
|
26
|
+
export declare const RADIUS_STEP = 10;
|
|
27
|
+
export declare const MAX_RADIUS = 1000;
|
|
28
|
+
export declare const isRadius: (n: unknown) => n is number;
|
|
29
|
+
/** An IANA zone name: `UTC`, `Etc/GMT+12`, `America/Argentina/Buenos_Aires`. Nothing else reaches callers. */
|
|
30
|
+
export declare const isZone: (zone: unknown) => zone is string;
|
|
31
|
+
/** Validate a table and return its entries. Throws a TypeError, prefixed with `name`, naming the first problem. */
|
|
32
|
+
export declare function readTable(table: unknown, name?: string): Entry[];
|
|
33
|
+
/** Serialise a table with sorted keys, one entry per line, so diffs stay readable. */
|
|
34
|
+
export declare function formatTable(points: Map<string, [string, number]>, maxRadius: number, geoTz?: string): string;
|
|
35
|
+
/** The geo-tz version a table was built with, if it recorded one. */
|
|
36
|
+
export declare const tableGeoTz: (table: unknown) => string | undefined;
|
|
37
|
+
/** The `--max-radius` a table was built with, if it recorded one. */
|
|
38
|
+
export declare const tableMaxRadius: (table: unknown) => number | undefined;
|
package/dist/table.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { keyOf, latLng, parseKey } from './key.js';
|
|
2
|
+
import { quote } from './text.js';
|
|
3
|
+
/** Radii are multiples of the probe spacing, up to a kilometre. */
|
|
4
|
+
export const RADIUS_STEP = 10;
|
|
5
|
+
export const MAX_RADIUS = 1000;
|
|
6
|
+
export const isRadius = (n) => Number.isInteger(n) && n >= 0 && n <= MAX_RADIUS && n % RADIUS_STEP === 0;
|
|
7
|
+
/** An IANA zone name: `UTC`, `Etc/GMT+12`, `America/Argentina/Buenos_Aires`. Nothing else reaches callers. */
|
|
8
|
+
// Also not a name Object.prototype already has: a consumer that keys a plain object by zone would
|
|
9
|
+
// otherwise have "__proto__" or "constructor" handed to it from a hostile table. No IANA name collides.
|
|
10
|
+
export const isZone = (zone) => typeof zone === 'string' && /^[\w+-]{1,32}(\/[\w+-]{1,32}){0,2}$/.test(zone) && !(zone in Object.prototype);
|
|
11
|
+
/** True for a JSON-ish object, including one from another realm (whose Object.prototype is not ours). */
|
|
12
|
+
const isPlainObject = (value) => {
|
|
13
|
+
if (value === null || typeof value !== 'object')
|
|
14
|
+
return false;
|
|
15
|
+
const proto = Object.getPrototypeOf(value);
|
|
16
|
+
return proto === null || Object.getPrototypeOf(proto) === null;
|
|
17
|
+
};
|
|
18
|
+
/** Validate a table and return its entries. Throws a TypeError, prefixed with `name`, naming the first problem. */
|
|
19
|
+
export function readTable(table, name = 'tz-at-point table') {
|
|
20
|
+
if (!isPlainObject(table))
|
|
21
|
+
throw new TypeError(`${name}: must be a plain object`);
|
|
22
|
+
// `import * as table from './zones.json'` gives a module namespace, not the table.
|
|
23
|
+
if (table[Symbol.toStringTag] === 'Module') {
|
|
24
|
+
throw new TypeError(`${name}: got a module namespace; pass the JSON module's default export`);
|
|
25
|
+
}
|
|
26
|
+
if (!Object.hasOwn(table, 'v') || table.v !== 1)
|
|
27
|
+
throw new TypeError(`${name}: unsupported version ${quote(table.v)}`);
|
|
28
|
+
if (!Object.hasOwn(table, 'points') || !isPlainObject(table.points))
|
|
29
|
+
throw new TypeError(`${name}: points must be a plain object`);
|
|
30
|
+
if (Object.hasOwn(table, 'attribution') && typeof table.attribution !== 'string')
|
|
31
|
+
throw new TypeError(`${name}: attribution must be a string`);
|
|
32
|
+
const zones = new Set(); // valid names already seen: a table repeats a few hundred at most
|
|
33
|
+
const entries = [];
|
|
34
|
+
for (const [key, value] of Object.entries(table.points)) {
|
|
35
|
+
const fail = (problem) => new TypeError(`${name}: entry ${quote(key)} ${problem}`);
|
|
36
|
+
const [lat, lng] = parseKey(key);
|
|
37
|
+
if (!latLng(lat, lng) || keyOf(lat, lng) !== key)
|
|
38
|
+
throw fail('is not a canonical point key');
|
|
39
|
+
if (!Array.isArray(value) || value.length !== 2)
|
|
40
|
+
throw fail('must map to [zone, radius]');
|
|
41
|
+
const [zone, radius] = value;
|
|
42
|
+
if (!zones.has(zone)) {
|
|
43
|
+
if (!isZone(zone))
|
|
44
|
+
throw fail('has an invalid zone name');
|
|
45
|
+
zones.add(zone);
|
|
46
|
+
}
|
|
47
|
+
if (!isRadius(radius))
|
|
48
|
+
throw fail(`has radius ${quote(radius)}; expected a multiple of ${RADIUS_STEP} from 0 to ${MAX_RADIUS}`);
|
|
49
|
+
entries.push({ key, lat, lng, zone, radius });
|
|
50
|
+
}
|
|
51
|
+
return entries;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Where the zones came from. Not required: the OSM Foundation's geocoding guideline treats a collection
|
|
55
|
+
* of query results like this as an insubstantial extract. It travels with the file so that whoever finds
|
|
56
|
+
* it later knows to credit OpenStreetMap in anything they ship.
|
|
57
|
+
*/
|
|
58
|
+
const ATTRIBUTION = 'Timezone boundaries from OpenStreetMap (https://www.openstreetmap.org/copyright), '
|
|
59
|
+
+ 'ODbL 1.0. Zone names from the IANA tz database, public domain.';
|
|
60
|
+
/** Serialise a table with sorted keys, one entry per line, so diffs stay readable. */
|
|
61
|
+
export function formatTable(points, maxRadius, geoTz) {
|
|
62
|
+
const lines = [...points.keys()].sort().map((key) => ` ${JSON.stringify(key)}: ${JSON.stringify(points.get(key))}`);
|
|
63
|
+
const lineage = geoTz === undefined ? '' : ` "geoTz": ${JSON.stringify(geoTz)},\n`;
|
|
64
|
+
return `{\n "v": 1,\n "attribution": ${JSON.stringify(ATTRIBUTION)},\n${lineage} "maxRadius": ${maxRadius},`
|
|
65
|
+
+ `\n "points": {\n${lines.join(',\n')}${lines.length ? '\n' : ''} }\n}\n`;
|
|
66
|
+
}
|
|
67
|
+
/** The geo-tz version a table was built with, if it recorded one. */
|
|
68
|
+
export const tableGeoTz = (table) => {
|
|
69
|
+
const value = table?.geoTz;
|
|
70
|
+
return typeof value === 'string' && /^[\w.+-]{1,32}$/.test(value) ? value : undefined;
|
|
71
|
+
};
|
|
72
|
+
/** The `--max-radius` a table was built with, if it recorded one. */
|
|
73
|
+
export const tableMaxRadius = (table) => {
|
|
74
|
+
const value = table?.maxRadius;
|
|
75
|
+
return isRadius(value) ? value : undefined;
|
|
76
|
+
};
|
package/dist/text.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape everything outside printable ASCII, so input cannot forge log lines or send terminal control codes.
|
|
3
|
+
* `keepNewlines` is for the usage text, which tz-at-point wrote itself and which spans lines.
|
|
4
|
+
*/
|
|
5
|
+
export declare const printable: (text: string, keepNewlines?: boolean) => string;
|
|
6
|
+
/** A short, printable rendering of an untrusted value for an error message. */
|
|
7
|
+
export declare function quote(value: unknown): string;
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape everything outside printable ASCII, so input cannot forge log lines or send terminal control codes.
|
|
3
|
+
* `keepNewlines` is for the usage text, which tz-at-point wrote itself and which spans lines.
|
|
4
|
+
*/
|
|
5
|
+
export const printable = (text, keepNewlines = false) => text.replace(keepNewlines ? /[^\n\x20-\x7e]/g : /[^\x20-\x7e]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
6
|
+
/** A short, printable rendering of an untrusted value for an error message. */
|
|
7
|
+
export function quote(value) {
|
|
8
|
+
let text;
|
|
9
|
+
try {
|
|
10
|
+
text = JSON.stringify(value) ?? String(value);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
text = Object.prototype.toString.call(value);
|
|
14
|
+
}
|
|
15
|
+
return printable(text.length > 40 ? `${text.slice(0, 40)}...` : text);
|
|
16
|
+
}
|
package/llms.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# tz-at-point
|
|
2
|
+
|
|
3
|
+
> The IANA timezone at a coordinate, exact at the points you list, with no file reads at runtime. Build a small JSON table offline with `npx tz-at-point build`, commit it, and call `createLookup(table)` in serverless code.
|
|
4
|
+
|
|
5
|
+
tz-at-point is a TypeScript/JavaScript library and CLI for Node 20.19+ or 22.12+. geo-tz is needed only at build time, as a dev dependency.
|
|
6
|
+
|
|
7
|
+
## Docs
|
|
8
|
+
|
|
9
|
+
- [README](README.md): overview, install, quick start, troubleshooting
|
|
10
|
+
- [Agent skill](skills/tz-at-point/SKILL.md): the workflow and rules a coding agent should follow
|
|
11
|
+
- [API reference](skills/tz-at-point/references/api.md): `createLookup`, `pointKey`, types and the table format
|
|
12
|
+
- [CLI reference](skills/tz-at-point/references/cli.md): every command, flag, message and exit code
|
|
13
|
+
- [Setup guide](skills/tz-at-point/references/setup.md): points files, JSON imports in TypeScript and bundlers, CI
|
|
14
|
+
|
|
15
|
+
## Optional
|
|
16
|
+
|
|
17
|
+
- [Architecture notes](https://github.com/oo-pibe/tz-at-point/blob/main/AGENTS.md): invariants, layout and tests for working on tz-at-point itself
|
|
18
|
+
- [Contributing](https://github.com/oo-pibe/tz-at-point/blob/main/CONTRIBUTING.md): how to propose a change
|
package/package.json
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tz-at-point",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Offline IANA timezone lookup for known coordinates in serverless and edge functions.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"timezone",
|
|
7
|
+
"iana",
|
|
8
|
+
"timezone-lookup",
|
|
9
|
+
"coordinates",
|
|
10
|
+
"offline",
|
|
11
|
+
"serverless",
|
|
12
|
+
"edge-runtime",
|
|
13
|
+
"geo-tz",
|
|
14
|
+
"build-time",
|
|
15
|
+
"lambda",
|
|
16
|
+
"latitude",
|
|
17
|
+
"longitude"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "oo-pibe",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/oo-pibe/tz-at-point.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/oo-pibe/tz-at-point#readme",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/oo-pibe/tz-at-point/issues",
|
|
28
|
+
"email": "contact@roadtokickoff.com"
|
|
29
|
+
},
|
|
30
|
+
"type": "module",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"default": "./dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./core": {
|
|
38
|
+
"types": "./dist/lookup.d.ts",
|
|
39
|
+
"default": "./dist/lookup.js"
|
|
40
|
+
},
|
|
41
|
+
"./package.json": "./package.json"
|
|
42
|
+
},
|
|
43
|
+
"main": "./dist/index.js",
|
|
44
|
+
"typesVersions": {
|
|
45
|
+
"*": {
|
|
46
|
+
"core": [
|
|
47
|
+
"./dist/lookup.d.ts"
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"bin": {
|
|
52
|
+
"tz-at-point": "dist/cli.js"
|
|
53
|
+
},
|
|
54
|
+
"files": [
|
|
55
|
+
"dist",
|
|
56
|
+
"skills",
|
|
57
|
+
"llms.txt"
|
|
58
|
+
],
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
|
|
64
|
+
"typecheck": "tsc",
|
|
65
|
+
"test": "node --test test/*.test.ts",
|
|
66
|
+
"prepack": "npm run build"
|
|
67
|
+
},
|
|
68
|
+
"dependencies": {
|
|
69
|
+
"@photostructure/tz-lookup": "^11.6.1"
|
|
70
|
+
},
|
|
71
|
+
"peerDependencies": {
|
|
72
|
+
"geo-tz": "^8.0.0"
|
|
73
|
+
},
|
|
74
|
+
"peerDependenciesMeta": {
|
|
75
|
+
"geo-tz": {
|
|
76
|
+
"optional": true
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"devDependencies": {
|
|
80
|
+
"@types/node": "^22",
|
|
81
|
+
"esbuild": "^0.28.2",
|
|
82
|
+
"geo-tz": "8.1.9",
|
|
83
|
+
"typescript": "^7.0.2"
|
|
84
|
+
},
|
|
85
|
+
"sideEffects": false,
|
|
86
|
+
"publishConfig": {
|
|
87
|
+
"access": "public",
|
|
88
|
+
"provenance": true
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tz-at-point
|
|
3
|
+
description: Use when a JavaScript or TypeScript project needs the IANA timezone or local time at latitude/longitude coordinates (venues, stadiums, stores, airports), when geo-tz fails with ENOENT on missing data files in a bundled or serverless function (Vercel, AWS Lambda, Netlify, Next.js), when a raster timezone lookup such as tz-lookup is an hour wrong near a border, or when working with the tz-at-point package, its zones.json table, or output from `tz-at-point build` or `tz-at-point check`.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# tz-at-point
|
|
8
|
+
|
|
9
|
+
tz-at-point answers the IANA timezone at a coordinate. `tz-at-point build` resolves your points offline against geo-tz's polygons into a small `zones.json`. At runtime, `createLookup(table)` answers from that table, then from the nearest table entry within its safe radius, then from a bundled raster for everything else. The runtime reads no files.
|
|
10
|
+
|
|
11
|
+
The messages and signatures below are checked against the package source by its test suite. You don't need to read tz-at-point's `dist/` source: the answers are here and in `references/`.
|
|
12
|
+
|
|
13
|
+
tz-at-point answers with a zone name, never a UTC offset. Get the offset for a given moment from `Intl.DateTimeFormat` with that `timeZone`.
|
|
14
|
+
|
|
15
|
+
## When not to use it
|
|
16
|
+
|
|
17
|
+
- Every point is far from any border and an approximate answer is fine: `@photostructure/tz-lookup` alone is enough.
|
|
18
|
+
- Browser-only code with no build step to produce the table.
|
|
19
|
+
|
|
20
|
+
## Checklist
|
|
21
|
+
|
|
22
|
+
Copy this and tick it off.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
- [ ] npm install tz-at-point && npm install --save-dev geo-tz (if geo-tz is in dependencies, this moves it out)
|
|
26
|
+
- [ ] Points file: .csv with `lat` and `lng` header columns, or .json array of [lat, lng] / { lat, lng } (extra fields ignored)
|
|
27
|
+
- [ ] npx tz-at-point build <points> -o <zones.json> (warnings go to stderr: read them)
|
|
28
|
+
- [ ] Commit zones.json next to the code that imports it
|
|
29
|
+
- [ ] Import the table as JSON and call createLookup(table) once, at module scope
|
|
30
|
+
- [ ] Remove every runtime readFileSync of data files: import them as JSON too (see "Data the function reads")
|
|
31
|
+
- [ ] Handle { zone: null } and treat source 'raster' for a known point as a stale table
|
|
32
|
+
- [ ] CI step 1: npx tz-at-point build <points> -o <zones.json> --check (a point was added without rebuilding)
|
|
33
|
+
- [ ] CI step 2: npx tz-at-point check <zones.json> (table edited, or geo-tz/Node changed)
|
|
34
|
+
- [ ] Once, to see what the table is worth here: npx tz-at-point check <zones.json> --raster
|
|
35
|
+
- [ ] After any geo-tz, tz-at-point or Node version change (npm update included): build --refresh, check, review the diff
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Data the function reads
|
|
39
|
+
|
|
40
|
+
`tz-at-point build` reads CSV directly, so `build` never needs a converter. The runtime is different: a serverless function can't `readFileSync` a CSV any more than geo-tz can read its data files. If the handler needs the venue list itself, keep that list as JSON, import it, and pass the same JSON file to `build`. One file, so the two cannot drift. Only if people must keep editing a CSV, generate the JSON from it in a script and check both in. If the columns are named differently (`latitude`, `lon`), see `references/setup.md`.
|
|
41
|
+
|
|
42
|
+
## Migrating from geo-tz
|
|
43
|
+
|
|
44
|
+
`build` uses `geo-tz/all`, not the default `geo-tz` export, which merges zones that have kept the same clocks since 1970. After migrating, some names change with identical local times: Baarle-Nassau `Europe/Brussels` becomes `Europe/Amsterdam`, Tromsø `Europe/Berlin` becomes `Europe/Oslo`. That is expected, not a bug. To double-check a point, compare with `import { find } from 'geo-tz/all'`, never the default export.
|
|
45
|
+
|
|
46
|
+
## Runtime rules
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
import { createLookup } from 'tz-at-point';
|
|
50
|
+
import table from './zones.json' with { type: 'json' };
|
|
51
|
+
|
|
52
|
+
const zoneAt = createLookup(table); // once, at module scope
|
|
53
|
+
|
|
54
|
+
export function localKickoff(lat, lng, utcIso) {
|
|
55
|
+
const { zone, source } = zoneAt(lat, lng);
|
|
56
|
+
if (zone === null) return null; // not a coordinate, or no fallback answer
|
|
57
|
+
return new Intl.DateTimeFormat('en-GB', {
|
|
58
|
+
timeZone: zone, weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
|
59
|
+
}).format(new Date(utcIso)); // 'Mon 21 Sept, 00:30' on Node 22.12+; punctuation varies with the runtime's ICU
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The local date can differ from the UTC date. If the user asks for a time only (`'20:45'`), give them that (`hour`, `minute`, `hourCycle: 'h23'`), but say that late kickoffs fall on the next local day and offer a date field.
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
// Tests (not the handler) may read files. Every known point must come from the table:
|
|
67
|
+
for (const v of venues) assert.ok(['table', 'table-near'].includes(zoneAt(v.lat, v.lng).source), `${v.name}: zones.json is stale`);
|
|
68
|
+
// not notEqual(source, 'raster'): that also passes for { zone: null } from bad coordinates
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- The JSON import is what puts the table inside the bundle. Reading `zones.json` with `fs` at runtime brings back the serverless file problem.
|
|
72
|
+
- geo-tz is a dev dependency, used only by `build` and `check`. Never import it at runtime.
|
|
73
|
+
- `createLookup` validates the table and throws a `TypeError` if it is malformed. The returned lookup never throws.
|
|
74
|
+
- `source`:
|
|
75
|
+
- `table`: the point rounds to a table key (4 decimals, a cell about 11m across).
|
|
76
|
+
- `table-near`: within an entry's safe radius. Correct and normal; not a warning sign.
|
|
77
|
+
- `raster`: outside the table, approximate near borders. For a point that is in your points file this means the committed table is stale. Worth a test: every known point should resolve with source `table` or `table-near` (the snippet above; not `!== 'raster'`, which a bad coordinate also passes).
|
|
78
|
+
|
|
79
|
+
## Never
|
|
80
|
+
|
|
81
|
+
- Hand-edit `zones.json`. Change the points file and run `build`.
|
|
82
|
+
- Run `build` or import geo-tz in a request handler.
|
|
83
|
+
- Use flags that don't exist. `build` takes `-o/--out`, `--check`, `--refresh`, `--max-radius` (multiple of 10, up to 1000, default 250), `-h/--help`. `check` takes the table path, `--raster` and `-h/--help`.
|
|
84
|
+
- Expect `build` to delete entries for removed points. It only adds. To prune, delete `zones.json` and build again.
|
|
85
|
+
|
|
86
|
+
## Reading the output
|
|
87
|
+
|
|
88
|
+
| Output | Meaning | Action |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `wrote zones.json: N points (M resolved)` | Table written | Commit it |
|
|
91
|
+
| `up to date: 5 points` | No point is missing. The count is table entries, not lines in your points file. `build` never re-validates existing entries; that is `check`'s job | None |
|
|
92
|
+
| `1 point missing from …` (exit 1) | `--check` found points not in the table, or there is no table yet | Run the `run: npx tz-at-point build …` line it prints, commit |
|
|
93
|
+
| `warning: KEY is within 10m of another zone; …` | That entry has radius 0: its whole ~11m key cell answers one zone, even the part across the border. Repeats on every build | Nothing, unless the point should be in the other zone. When both zones keep the same clocks (Amsterdam and Brussels), local times are unaffected either way. To check a specific point, resolve it with `geo-tz/all` yourself |
|
|
94
|
+
| `warning: LAT,LNG is in A, but its key KEY is in B; …` | The point itself is across the border from its rounded key. Printed only when that key is first added, so silence does not prove a point is on its key's side | Lookups there answer B. Nudge the coordinate onto the correct side if it matters |
|
|
95
|
+
| `FAIL KEY: table says A, polygons say B` (exit 1) | Table disagrees with the installed geo-tz (edited, merged badly, or boundaries changed) | `npx tz-at-point build <points> -o <zones.json> --refresh` with the same `--max-radius` you built with, then `check`, review `git diff`, commit |
|
|
96
|
+
| `FAIL KEY: radius Rm reaches another zone` | Boundaries moved closer | Same as above |
|
|
97
|
+
| `FAIL ZONE: not a zone this runtime's Intl accepts` | The Node running `check` is too old for that zone | Update Node. Refreshing won't change the name, unless the same entry also failed with `table says …` |
|
|
98
|
+
| `tz-at-point: …` (exit 2) | Bad arguments or input | Read the message; see references/cli.md |
|
|
99
|
+
|
|
100
|
+
Exit codes: 0 success, 1 a check found a problem, 2 bad arguments or input.
|
|
101
|
+
|
|
102
|
+
## References
|
|
103
|
+
|
|
104
|
+
- [references/api.md](references/api.md): `createLookup`, `pointKey`, types, table format, performance. Open when writing code against the API.
|
|
105
|
+
- [references/cli.md](references/cli.md): every flag, message and exit code. Open when a command prints something not in the table above.
|
|
106
|
+
- [references/setup.md](references/setup.md): points files from other data, TypeScript and bundler JSON imports, CI workflow, upgrading geo-tz, tests. Open when wiring tz-at-point into a project.
|