lltz 0.0.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Utkarsh Kukreti
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # lltz (Latitude & Longitude → Time Zone)
2
+
3
+ A high-performance, memory-efficient offline timezone lookup library for TypeScript using a custom binary format and quadtree spatial indexing.
4
+
5
+ This library uses GeoJSON data from [timezone-boundary-builder](https://github.com/evansiroky/timezone-boundary-builder).
6
+
7
+ ## Features
8
+
9
+ - **Fast**: Performs **25-35 million lookups per second** (~30-40ns per op) on an Apple M4 Pro CPU.
10
+ - **Tiny Memory Footprint**: Operates directly on raw binary data (ArrayBuffer/Uint8Array). Memory usage is limited to the LLTZ binary file size (approximately **26-44MiB**), with no additional object overhead.
11
+ - **Zero Dependencies**: A lightweight, standalone TypeScript library with no external runtime dependencies.
12
+ - **Universal**: Runs in both server (Node.js, Bun) and client (browser) environments.
13
+ - **High Accuracy**: Validated to match [`geo-tz`](https://www.npmjs.com/package/geo-tz) results in **>99.99%** of cases.
14
+
15
+ ## Usage
16
+
17
+ ### Installation
18
+
19
+ ```bash
20
+ pnpm add lltz # or npm, yarn, bun
21
+ ```
22
+
23
+ ### Data
24
+
25
+ Three data variants are available, as described in the [timezone-boundary-builder README](https://github.com/evansiroky/timezone-boundary-builder#readme). We recommend `timezones.lltz` for the most comprehensive coverage, which is included in the NPM package.
26
+
27
+ The other variants, `timezones-1970.lltz` and `timezones-now.lltz`, can be downloaded from the [releases page](https://github.com/utkarshkukreti/lltz/releases) and loaded similarly.
28
+
29
+ ### Node.js / Bun
30
+
31
+ ```typescript
32
+ import fs from 'node:fs'
33
+
34
+ import * as Lltz from 'lltz'
35
+
36
+ const buffer = fs.readFileSync(new URL(import.meta.resolve('lltz/data/timezones.lltz')))
37
+ // ↳ or fs.readFileSync('node_modules/lltz/data/timezones.lltz')
38
+ const lookup = Lltz.make(buffer)
39
+
40
+ const timezones = lookup(40.7128, -74.006) // New York
41
+ console.log(timezones) // ['America/New_York']
42
+ ```
43
+
44
+ ### Browser
45
+
46
+ ```typescript
47
+ import * as Lltz from 'lltz'
48
+
49
+ const response = await fetch('/path/to/timezones.lltz')
50
+ const arrayBuffer = await response.arrayBuffer()
51
+ const lookup = Lltz.make(arrayBuffer)
52
+
53
+ console.log(lookup(40.7128, -74.006)) // ['America/New_York']
54
+ ```
55
+
56
+ ## Architecture
57
+
58
+ ### Binary Format (.lltz)
59
+
60
+ The LLTZ binary file format is designed for efficient querying on raw bytes. It consists of the following sections:
61
+
62
+ - **Header**: The first eight bytes are `LLTZ1\0\0\0`.
63
+ - **Timezone Strings**: A null-terminated list of timezone IDs.
64
+ - **Grid Index**: A 180x360 coarse grid (1-degree resolution) for O(1) access.
65
+ - **QuadTree Nodes**: Hierarchical spatial subdivision for complex regions.
66
+ - **Polygon Data**: Compressed relative integer coordinates for final containment checks.
67
+
68
+ ### Builder & Runtime
69
+
70
+ - **Builder (Python)**: Normalizes `timezone-boundary-builder` GeoJSON files to a 1,000,000 scale grid and constructs the spatial index.
71
+ - **Runtime (TypeScript)**: Performs an initial O(1) lookup using a coarse grid. For points near boundaries, it falls back to precise point-in-polygon ray-casting. Oceans default to `Etc/GMT` offsets based on 15-degree longitude bands.
72
+
73
+ ## Development
74
+
75
+ ### Data Preparation
76
+
77
+ To download the latest GeoJSON data and convert it into the optimized LLTZ binary format, run:
78
+
79
+ > Requires [uv](https://docs.astral.sh/uv/) to be installed.
80
+
81
+ ```bash
82
+ make -j
83
+ ```
84
+
85
+ ### Running Tests
86
+
87
+ Test lookup results against the `geo-tz` package:
88
+
89
+ ```bash
90
+ pnpm test
91
+ ```
92
+
93
+ ### Benchmarks
94
+
95
+ Benchmark performance against the `geo-tz` package:
96
+
97
+ ```bash
98
+ pnpm bench
99
+ ```
100
+
101
+ Benchmark memory usage against the `geo-tz` package:
102
+
103
+ ```bash
104
+ pnpm bench:memory
105
+ ```
106
+
107
+ ## License
108
+
109
+ MIT for the code, [ODbL for the data](https://github.com/evansiroky/timezone-boundary-builder).
Binary file
@@ -0,0 +1,21 @@
1
+ //#region src/index.d.ts
2
+ type Lookup =
3
+ /**
4
+ * Returns an array of timezone IDs for the given latitude and longitude.
5
+ * @param latitude - The latitude of the point in degrees (-90 to 90).
6
+ * @param longitude - The longitude of the point in degrees (-180 to 180).
7
+ * @throws An error if the latitude or longitude is out of range.
8
+ * @returns An array of timezone IDs. For points in unmapped areas (e.g., oceans), returns
9
+ * 'Etc/GMT'-based timezones.
10
+ */
11
+ (latitude: number, longitude: number) => string[];
12
+ /**
13
+ * Creates a timezone lookup function from the provided binary data.
14
+ *
15
+ * @param arrayBufferOrUint8Array - The binary data containing the timezone database (LLTZ format).
16
+ * @returns {Lookup} A timezone lookup function.
17
+ * @throws An error if the binary data is invalid.
18
+ */
19
+ declare const make: (arrayBufferOrUint8Array: ArrayBuffer | Uint8Array) => Lookup;
20
+ //#endregion
21
+ export { Lookup, make };
package/dist/index.js ADDED
@@ -0,0 +1,186 @@
1
+ //#region src/index.ts
2
+ /**
3
+ * The expected header for the LLTZ binary file format.
4
+ */
5
+ const HEADER = "LLTZ1\0\0\0";
6
+ /**
7
+ * The scale factor used to convert floating-point coordinates to integers in the binary format.
8
+ * 1 degree = 1,000,000 units.
9
+ */
10
+ const SCALE = 1e6;
11
+ /**
12
+ * Default timezones returned when querying latitude 90 (North Pole).
13
+ * Since lines of longitude converge at the poles, all GMT offsets are technically valid.
14
+ */
15
+ const DEFAULT_LATITUDE_90 = [
16
+ "Etc/GMT",
17
+ ...Array.from({ length: 12 }, (_, i) => `Etc/GMT+${i + 1}`),
18
+ ...Array.from({ length: 12 }, (_, i) => `Etc/GMT-${i + 1}`)
19
+ ];
20
+ /**
21
+ * Default timezones returned when querying longitude -180 or 180 (International Date Line).
22
+ * Can be either GMT+12 or GMT-12.
23
+ */
24
+ const DEFAULT_LONGITUDE_ABS_180 = ["Etc/GMT+12", "Etc/GMT-12"];
25
+ /**
26
+ * Checks if the point (x, y) is inside or on the boundary of a polygon ring using the ray casting
27
+ * algorithm.
28
+ * @param dataView - The DataView of the binary data.
29
+ * @param x - The integer-scaled x coordinate of the point relative to the polygon's base x.
30
+ * @param y - The integer-scaled y coordinate of the point relative to the polygon's base y.
31
+ * @param offset - The offset to the start of the polygon ring.
32
+ * @returns A tuple containing a status ('in' | 'on' | false) indicating if the point is inside, on
33
+ * the boundary, or outside the ring, and the offset to the next polygon ring.
34
+ */
35
+ const isPointInOrOnRing = (dataView, x, y, offset) => {
36
+ const pointsCount = dataView.getUint16(offset, true);
37
+ offset += 2;
38
+ let xPrevious = dataView.getUint16(offset, true);
39
+ offset += 2;
40
+ let yPrevious = dataView.getUint16(offset, true);
41
+ offset += 2;
42
+ const xFirst = xPrevious;
43
+ const yFirst = yPrevious;
44
+ let inside = false;
45
+ for (let i = 1; i <= pointsCount; i++) {
46
+ let xCurrent = xFirst;
47
+ let yCurrent = yFirst;
48
+ if (i < pointsCount) {
49
+ xCurrent = dataView.getUint16(offset, true);
50
+ offset += 2;
51
+ yCurrent = dataView.getUint16(offset, true);
52
+ offset += 2;
53
+ }
54
+ const dx = xCurrent - xPrevious;
55
+ const dy = yCurrent - yPrevious;
56
+ const dpx = x - xPrevious;
57
+ const crossProduct = dx * (y - yPrevious) - dy * dpx;
58
+ if (crossProduct === 0 && (x >= xPrevious && x <= xCurrent || x >= xCurrent && x <= xPrevious) && (y >= yPrevious && y <= yCurrent || y >= yCurrent && y <= yPrevious)) return ["on", offset];
59
+ if (yCurrent > y !== yPrevious > y) {
60
+ if (yCurrent > yPrevious === crossProduct > 0) inside = !inside;
61
+ }
62
+ xPrevious = xCurrent;
63
+ yPrevious = yCurrent;
64
+ }
65
+ return [inside ? "in" : false, offset];
66
+ };
67
+ /**
68
+ * Checks if the point (x, y) is inside a polygon, accounting for holes (inner rings).
69
+ * @param dataView - The DataView of the binary data.
70
+ * @param x - The integer-scaled x coordinate of the point.
71
+ * @param y - The integer-scaled y coordinate of the point.
72
+ * @param offset - The offset to the start of the polygon.
73
+ * @param xMinBase - The integer-scaled base x coordinate of the polygon.
74
+ * @param yMinBase - The integer-scaled base y coordinate of the polygon.
75
+ * @returns A tuple containing a boolean indicating if the point is inside or on the boundary of the
76
+ * polygon and the offset to the next polygon.
77
+ */
78
+ const isPointInPolygon = (dataView, x, y, offset, xMinBase, yMinBase) => {
79
+ const size = dataView.getUint16(offset, true);
80
+ offset += 2;
81
+ const nextPolygonOffset = offset + size;
82
+ const ringsCount = dataView.getUint8(offset);
83
+ offset += 1;
84
+ const xMin = dataView.getUint16(offset, true) + xMinBase;
85
+ offset += 2;
86
+ const xMax = dataView.getUint16(offset, true) + xMinBase;
87
+ offset += 2;
88
+ const yMin = dataView.getUint16(offset, true) + yMinBase;
89
+ offset += 2;
90
+ const yMax = dataView.getUint16(offset, true) + yMinBase;
91
+ offset += 2;
92
+ if (x < xMin || x > xMax || y < yMin || y > yMax) return [false, nextPolygonOffset];
93
+ const [where, nextRingOffset] = isPointInOrOnRing(dataView, x - xMinBase, y - yMinBase, offset);
94
+ if (where !== "in") return [where === "on", nextPolygonOffset];
95
+ offset = nextRingOffset;
96
+ for (let i = 1; i < ringsCount; i++) {
97
+ const [where$1, nextRingOffset$1] = isPointInOrOnRing(dataView, x - xMinBase, y - yMinBase, offset);
98
+ if (where$1 !== false) return [where$1 === "on", nextPolygonOffset];
99
+ offset = nextRingOffset$1;
100
+ }
101
+ return [true, nextPolygonOffset];
102
+ };
103
+ /**
104
+ * Creates a timezone lookup function from the provided binary data.
105
+ *
106
+ * @param arrayBufferOrUint8Array - The binary data containing the timezone database (LLTZ format).
107
+ * @returns {Lookup} A timezone lookup function.
108
+ * @throws An error if the binary data is invalid.
109
+ */
110
+ const make = (arrayBufferOrUint8Array) => {
111
+ const bytes = ArrayBuffer.isView(arrayBufferOrUint8Array) ? arrayBufferOrUint8Array : new Uint8Array(arrayBufferOrUint8Array);
112
+ const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
113
+ let offset = 0;
114
+ for (let i = 0; i < 8; i++) if (dataView.getUint8(offset + i) !== HEADER.charCodeAt(i)) throw new Error(`invalid file format: missing header ${JSON.stringify(HEADER)}`);
115
+ offset += 8;
116
+ const timezonesLength = dataView.getUint16(offset, true);
117
+ offset += 2;
118
+ const timezones = new TextDecoder().decode(bytes.subarray(offset, offset + timezonesLength)).split("\0");
119
+ offset += timezonesLength;
120
+ const offsetsOffset = offset;
121
+ offset += 180 * 360 * 4;
122
+ const baseOffset = offset;
123
+ return (latitude, longitude) => {
124
+ if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) throw new Error(`invalid latitude or longitude: ${latitude}, ${longitude}`);
125
+ const latitudeIndex = Math.min(latitude + 90 | 0, 179);
126
+ const longitudeIndex = Math.min(longitude + 180 | 0, 359);
127
+ let value = dataView.getUint32(offsetsOffset + (latitudeIndex * 360 + longitudeIndex) * 4, true);
128
+ let tag = value >>> 30;
129
+ if (tag === 1) return [timezones[value & (1 << 30) - 1]];
130
+ if (tag !== 0) {
131
+ const latitudeInteger = (latitude + 90) * SCALE + .5 | 0;
132
+ const longitudeInteger = (longitude + 180) * SCALE + .5 | 0;
133
+ let offset$1 = baseOffset;
134
+ let xMin = longitudeIndex * SCALE;
135
+ let yMin = latitudeIndex * SCALE;
136
+ let xMax = xMin + SCALE;
137
+ let yMax = yMin + SCALE;
138
+ while (tag === 3) {
139
+ offset$1 += value & (1 << 30) - 1;
140
+ const xMid = xMin + xMax >> 1;
141
+ const yMid = yMin + yMax >> 1;
142
+ const quadtreeIndex = (latitudeInteger >= yMid ? 1 : 0) << 1 | (longitudeInteger >= xMid ? 1 : 0);
143
+ value = dataView.getUint32(offset$1 + quadtreeIndex * 4, true);
144
+ tag = value >>> 30;
145
+ offset$1 += 16;
146
+ latitudeInteger >= yMid ? yMin = yMid : yMax = yMid;
147
+ longitudeInteger >= xMid ? xMin = xMid : xMax = xMid;
148
+ }
149
+ if (tag === 1) return [timezones[value & (1 << 30) - 1]];
150
+ else if (tag === 2) {
151
+ const output = [];
152
+ offset$1 += value & (1 << 30) - 1;
153
+ const count = dataView.getUint8(offset$1);
154
+ offset$1 += 1;
155
+ for (let i = 0; i < count; i++) {
156
+ const index = dataView.getUint16(offset$1, true);
157
+ offset$1 += 2;
158
+ const polygonsCount = dataView.getUint8(offset$1);
159
+ offset$1 += 1;
160
+ for (let j = 0; j < polygonsCount; j++) {
161
+ const [isIn, offset_] = isPointInPolygon(dataView, longitudeInteger, latitudeInteger, offset$1, xMin, yMin);
162
+ offset$1 = offset_;
163
+ if (isIn) {
164
+ output.push(timezones[index]);
165
+ for (let k = j + 1; k < polygonsCount; k++) offset$1 += 2 + dataView.getUint16(offset$1, true);
166
+ break;
167
+ }
168
+ }
169
+ }
170
+ if (output.length > 0) return output;
171
+ }
172
+ }
173
+ if (latitude === 90) return DEFAULT_LATITUDE_90;
174
+ else if (longitude === -180 || longitude === 180) return DEFAULT_LONGITUDE_ABS_180;
175
+ else {
176
+ const output = [];
177
+ const min = Math.ceil(longitude / 15 - .5);
178
+ const max = Math.floor(longitude / 15 + .5);
179
+ for (let n = min; n <= max; n++) output.push(n === 0 ? "Etc/GMT" : n > 0 ? `Etc/GMT-${n}` : `Etc/GMT+${-n}`);
180
+ return output;
181
+ }
182
+ };
183
+ };
184
+
185
+ //#endregion
186
+ export { make };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "lltz",
3
+ "version": "0.0.1",
4
+ "author": "Utkarsh Kukreti <utkarshkukreti@gmail.com>",
5
+ "description": "A high-performance, memory-efficient offline timezone lookup library for TypeScript using a custom binary format and quadtree spatial indexing.",
6
+ "keywords": [
7
+ "geo",
8
+ "latitude",
9
+ "longitude",
10
+ "lookup",
11
+ "timezone"
12
+ ],
13
+ "homepage": "https://github.com/utkarshkukreti/lltz",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/utkarshkukreti/lltz.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/utkarshkukreti/lltz/issues"
20
+ },
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "module": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": "./dist/index.js",
28
+ "./data/*": "./data/*"
29
+ },
30
+ "files": [
31
+ "data/timezones.lltz",
32
+ "dist"
33
+ ],
34
+ "devDependencies": {
35
+ "@ianvs/prettier-plugin-sort-imports": "4.7.0",
36
+ "@types/node": "24.10.8",
37
+ "geo-tz": "8.1.5",
38
+ "prettier": "3.7.4",
39
+ "tinybench": "6.0.0",
40
+ "tsdown": "0.19.0",
41
+ "typescript": "5.9.3"
42
+ },
43
+ "scripts": {
44
+ "build": "tsc && tsdown",
45
+ "lint": "prettier --check .",
46
+ "format": "prettier --write .",
47
+ "test": "node --max-old-space-size=8192 tests/index.ts",
48
+ "bench": "node benches/index.ts",
49
+ "bench:memory": "node benches/memory.ts lltz:timezones && node benches/memory.ts geo-tz:timezones"
50
+ }
51
+ }