thaizip 0.7.2 → 0.7.3
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 +5 -1
- package/dist/data.cjs +46 -6
- package/dist/data.d.cts +9 -0
- package/dist/data.d.ts +9 -0
- package/dist/data.js +46 -6
- package/dist/index.cjs +96 -17
- package/dist/index.d.cts +33 -2
- package/dist/index.d.ts +33 -2
- package/dist/index.js +96 -17
- package/dist/react.cjs +62 -31
- package/dist/react.d.cts +9 -0
- package/dist/react.d.ts +9 -0
- package/dist/react.js +62 -31
- package/package.json +5 -2
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/react.cjs.map +0 -1
- package/dist/react.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
var TONE_MARKS = /[่-๋็์]/g;
|
|
5
5
|
var ADDRESS_PREFIXES = /^(จังหวัด|อำเภอ|ตำบล|แขวง|เขต|จ\.|อ\.|ต\.|ข\.)/;
|
|
6
6
|
function normalizeThaiAddressText(input) {
|
|
7
|
-
if (
|
|
8
|
-
|
|
7
|
+
if (typeof input !== "string") {
|
|
8
|
+
throw new TypeError(
|
|
9
|
+
`[thaizip] normalizeThaiAddressText expected string, got ${input === null ? "null" : typeof input}`
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
if (input.length === 0) return "";
|
|
13
|
+
let text = input.normalize("NFC").trim();
|
|
9
14
|
text = text.replace(ADDRESS_PREFIXES, "").trim();
|
|
10
15
|
text = text.replace(TONE_MARKS, "");
|
|
11
16
|
text = text.toLowerCase();
|
|
@@ -33,24 +38,57 @@ function extractTrigramsNormalized(text) {
|
|
|
33
38
|
function typeErr(table, id, field, expected, value) {
|
|
34
39
|
return new TypeError(`[thaizip] ${table} ${id}: expected ${expected} for ${field}, got ${typeof value}`);
|
|
35
40
|
}
|
|
41
|
+
function typeName(value) {
|
|
42
|
+
return value === null ? "null" : typeof value;
|
|
43
|
+
}
|
|
36
44
|
function validateRawData(data) {
|
|
45
|
+
if (data === null || typeof data !== "object") {
|
|
46
|
+
throw new TypeError(`[thaizip] validateRawData expected a RawData object, got ${typeName(data)}`);
|
|
47
|
+
}
|
|
37
48
|
const { provinces, amphures, tambons } = data;
|
|
49
|
+
if (!Array.isArray(provinces)) {
|
|
50
|
+
throw new TypeError(`[thaizip] RawData.provinces must be an array, got ${typeName(provinces)}`);
|
|
51
|
+
}
|
|
52
|
+
if (!Array.isArray(amphures)) {
|
|
53
|
+
throw new TypeError(`[thaizip] RawData.amphures must be an array, got ${typeName(amphures)}`);
|
|
54
|
+
}
|
|
55
|
+
if (!Array.isArray(tambons)) {
|
|
56
|
+
throw new TypeError(`[thaizip] RawData.tambons must be an array, got ${typeName(tambons)}`);
|
|
57
|
+
}
|
|
58
|
+
const seenProvinceIds = /* @__PURE__ */ new Set();
|
|
38
59
|
for (let i = 0; i < provinces.length; i++) {
|
|
39
60
|
const p = provinces[i];
|
|
61
|
+
if (p === null || typeof p !== "object") {
|
|
62
|
+
throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
|
|
63
|
+
}
|
|
40
64
|
if (typeof p.id !== "number") throw typeErr("province", p.id, "id", "number", p.id);
|
|
65
|
+
if (seenProvinceIds.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
|
|
66
|
+
seenProvinceIds.add(p.id);
|
|
41
67
|
if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
|
|
42
68
|
if (typeof p.name_en !== "string") throw typeErr("province", p.id, "name_en", "string", p.name_en);
|
|
43
69
|
}
|
|
70
|
+
const seenAmphureIds = /* @__PURE__ */ new Set();
|
|
44
71
|
for (let i = 0; i < amphures.length; i++) {
|
|
45
72
|
const a = amphures[i];
|
|
73
|
+
if (a === null || typeof a !== "object") {
|
|
74
|
+
throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
|
|
75
|
+
}
|
|
46
76
|
if (typeof a.id !== "number") throw typeErr("amphure", a.id, "id", "number", a.id);
|
|
77
|
+
if (seenAmphureIds.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
|
|
78
|
+
seenAmphureIds.add(a.id);
|
|
47
79
|
if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
|
|
48
80
|
if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
|
|
49
81
|
if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
|
|
50
82
|
}
|
|
83
|
+
const seenTambonIds = /* @__PURE__ */ new Set();
|
|
51
84
|
for (let i = 0; i < tambons.length; i++) {
|
|
52
85
|
const t = tambons[i];
|
|
86
|
+
if (t === null || typeof t !== "object") {
|
|
87
|
+
throw new TypeError(`[thaizip] tambon[${i}]: expected object, got ${typeName(t)}`);
|
|
88
|
+
}
|
|
53
89
|
if (typeof t.id !== "number") throw typeErr("tambon", t.id, "id", "number", t.id);
|
|
90
|
+
if (seenTambonIds.has(t.id)) throw new TypeError(`[thaizip] duplicate tambon id: ${t.id}`);
|
|
91
|
+
seenTambonIds.add(t.id);
|
|
54
92
|
if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
|
|
55
93
|
if (typeof t.name_en !== "string") throw typeErr("tambon", t.id, "name_en", "string", t.name_en);
|
|
56
94
|
const zipType = typeof t.zip_code;
|
|
@@ -147,7 +185,9 @@ function buildThaiAddressIndex(data, options) {
|
|
|
147
185
|
addTrigrams(map, provTrigrams.get(province.id), idx);
|
|
148
186
|
addTrigrams(map, ampTrigrams.get(amphure.id), idx);
|
|
149
187
|
}
|
|
150
|
-
|
|
188
|
+
const sortedZipKeys = [...zipIndex.keys()].sort();
|
|
189
|
+
const sortedZipPostings = sortedZipKeys.map((z) => zipIndex.get(z));
|
|
190
|
+
return { map, records, zipIndex, sortedZipKeys, sortedZipPostings, normTambon, normTambonEn, byProvince, byAmphure };
|
|
151
191
|
}
|
|
152
192
|
|
|
153
193
|
// src/core/romanize.ts
|
|
@@ -276,19 +316,40 @@ function sortZipMatches(matches, exactZip) {
|
|
|
276
316
|
});
|
|
277
317
|
return matches;
|
|
278
318
|
}
|
|
319
|
+
function normalizeLimit(value) {
|
|
320
|
+
if (Number.isNaN(value) || value === -Infinity) return 0;
|
|
321
|
+
if (value === Infinity) return value;
|
|
322
|
+
return Math.max(0, Math.floor(value));
|
|
323
|
+
}
|
|
279
324
|
function lookupByZipCode(index, zip, options) {
|
|
280
|
-
if (!index || !zip) return [];
|
|
325
|
+
if (!index || !index.zipIndex || !index.records || typeof zip !== "string") return [];
|
|
281
326
|
const normalized = zip.trim();
|
|
282
327
|
if (!ZIP_CODE_RE.test(normalized) || normalized.length < 2) return [];
|
|
283
|
-
const zipLimit = options?.zipLimit ?? Infinity;
|
|
328
|
+
const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
|
|
284
329
|
const matches = [];
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
330
|
+
const keys = index.sortedZipKeys;
|
|
331
|
+
const postings = index.sortedZipPostings;
|
|
332
|
+
if (keys !== void 0 && postings !== void 0) {
|
|
333
|
+
let lo = 0;
|
|
334
|
+
let hi = keys.length;
|
|
335
|
+
while (lo < hi) {
|
|
336
|
+
const mid = lo + hi >> 1;
|
|
337
|
+
if (keys[mid] < normalized) lo = mid + 1;
|
|
338
|
+
else hi = mid;
|
|
339
|
+
}
|
|
340
|
+
for (let i = lo; i < keys.length && keys[i].startsWith(normalized); i++) {
|
|
341
|
+
const indices = postings[i];
|
|
342
|
+
for (let j = 0; j < indices.length; j++) matches.push(index.records[indices[j]]);
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
for (const [z, indices] of index.zipIndex) {
|
|
346
|
+
if (z.startsWith(normalized)) {
|
|
347
|
+
for (const idx of indices) matches.push(index.records[idx]);
|
|
348
|
+
}
|
|
288
349
|
}
|
|
350
|
+
sortZipMatches(matches, normalized);
|
|
289
351
|
}
|
|
290
|
-
|
|
291
|
-
return matches.slice(0, zipLimit);
|
|
352
|
+
return matches.slice(0, zipLimit).map((record) => ({ ...record }));
|
|
292
353
|
}
|
|
293
354
|
function rankAgainstName(name, query) {
|
|
294
355
|
if (name === void 0 || name.length === 0) return 0;
|
|
@@ -303,10 +364,20 @@ function computeMatchRank(index, idx, query) {
|
|
|
303
364
|
const en = rankAgainstName(index.normTambonEn?.[idx], query);
|
|
304
365
|
return en > th ? en : th;
|
|
305
366
|
}
|
|
367
|
+
var scratchByIndex = /* @__PURE__ */ new WeakMap();
|
|
368
|
+
function getScratch(index) {
|
|
369
|
+
const n = index.records.length;
|
|
370
|
+
let s = scratchByIndex.get(index);
|
|
371
|
+
if (s === void 0 || s.counts.length < n) {
|
|
372
|
+
s = { counts: new Uint32Array(n), touched: new Int32Array(n) };
|
|
373
|
+
scratchByIndex.set(index, s);
|
|
374
|
+
}
|
|
375
|
+
return s;
|
|
376
|
+
}
|
|
306
377
|
function searchThaiAddress(index, query, options) {
|
|
307
|
-
const limit = options?.limit ?? 10;
|
|
378
|
+
const limit = normalizeLimit(options?.limit ?? 10);
|
|
308
379
|
const threshold = options?.threshold ?? 0.4;
|
|
309
|
-
if (!index || !query) return [];
|
|
380
|
+
if (!index || !index.map || !index.records || typeof query !== "string") return [];
|
|
310
381
|
if (query.length > 1e3) return [];
|
|
311
382
|
const normalized = normalizeThaiAddressText(query);
|
|
312
383
|
if (normalized.length === 0) return [];
|
|
@@ -320,17 +391,22 @@ function searchThaiAddress(index, query, options) {
|
|
|
320
391
|
if (normalized.length < 3) return [];
|
|
321
392
|
const searchText = options?.romanizationAliases !== false && HAS_LATIN_RE.test(normalized) ? applyRomanizationAliases(normalized) : normalized;
|
|
322
393
|
const queryTrigrams = extractTrigramsNormalized(searchText);
|
|
323
|
-
const
|
|
394
|
+
const { counts, touched } = getScratch(index);
|
|
395
|
+
let touchedLen = 0;
|
|
324
396
|
for (const trigram of queryTrigrams) {
|
|
325
397
|
const candidates = index.map.get(trigram);
|
|
326
398
|
if (!candidates) continue;
|
|
327
399
|
for (const idx of candidates) {
|
|
328
|
-
|
|
400
|
+
if (counts[idx] === 0) touched[touchedLen++] = idx;
|
|
401
|
+
counts[idx]++;
|
|
329
402
|
}
|
|
330
403
|
}
|
|
331
404
|
const scored = [];
|
|
332
|
-
|
|
333
|
-
|
|
405
|
+
const totalTrigrams = queryTrigrams.size;
|
|
406
|
+
for (let i = 0; i < touchedLen; i++) {
|
|
407
|
+
const idx = touched[i];
|
|
408
|
+
const score = counts[idx] / totalTrigrams;
|
|
409
|
+
counts[idx] = 0;
|
|
334
410
|
if (score >= threshold) {
|
|
335
411
|
scored.push({ idx, score, matchRank: computeMatchRank(index, idx, searchText) });
|
|
336
412
|
}
|
|
@@ -348,7 +424,7 @@ function searchThaiAddress(index, query, options) {
|
|
|
348
424
|
const rb = index.records[b.idx];
|
|
349
425
|
return TH_COLLATOR.compare(ra.provinceNameTh, rb.provinceNameTh) || TH_COLLATOR.compare(ra.amphureNameTh, rb.amphureNameTh) || TH_COLLATOR.compare(ra.tambonNameTh, rb.tambonNameTh);
|
|
350
426
|
});
|
|
351
|
-
return window.slice(0, limit).map(({ idx }) => index.records[idx]);
|
|
427
|
+
return window.slice(0, limit).map(({ idx }) => ({ ...index.records[idx] }));
|
|
352
428
|
}
|
|
353
429
|
|
|
354
430
|
// src/core/enumerate.ts
|
|
@@ -357,6 +433,7 @@ function byNameTh(a, b) {
|
|
|
357
433
|
return thCollator.compare(a.nameTh, b.nameTh);
|
|
358
434
|
}
|
|
359
435
|
function listProvinces(index) {
|
|
436
|
+
if (!index || !index.records) return [];
|
|
360
437
|
const seen = /* @__PURE__ */ new Map();
|
|
361
438
|
if (index.byProvince) {
|
|
362
439
|
for (const [provinceId, recordIndices] of index.byProvince) {
|
|
@@ -374,6 +451,7 @@ function listProvinces(index) {
|
|
|
374
451
|
return Array.from(seen.values()).sort(byNameTh);
|
|
375
452
|
}
|
|
376
453
|
function listAmphures(index, provinceId) {
|
|
454
|
+
if (!index || !index.records) return [];
|
|
377
455
|
const seen = /* @__PURE__ */ new Map();
|
|
378
456
|
const addFrom = (r) => {
|
|
379
457
|
if (!seen.has(r.amphureId)) {
|
|
@@ -397,6 +475,7 @@ function listAmphures(index, provinceId) {
|
|
|
397
475
|
return Array.from(seen.values()).sort(byNameTh);
|
|
398
476
|
}
|
|
399
477
|
function listTambons(index, amphureId) {
|
|
478
|
+
if (!index || !index.records) return [];
|
|
400
479
|
const toSummary = (r) => ({
|
|
401
480
|
id: r.tambonId,
|
|
402
481
|
nameTh: r.tambonNameTh,
|
package/dist/index.d.cts
CHANGED
|
@@ -14,6 +14,15 @@ type TrigramIndex = {
|
|
|
14
14
|
map: Map<string, Set<number>>;
|
|
15
15
|
records: ThaiAddressRecord[];
|
|
16
16
|
zipIndex: Map<string, number[]>;
|
|
17
|
+
/**
|
|
18
|
+
* Zip codes of `zipIndex` in ascending lexical order, with postings
|
|
19
|
+
* parallel in `sortedZipPostings`. Used by `lookupByZipCode` for
|
|
20
|
+
* O(log n + matches) prefix lookup instead of scanning every zip code.
|
|
21
|
+
* Optional: older-shaped or hand-built indexes without these fields fall
|
|
22
|
+
* back to the O(n_zips) scan.
|
|
23
|
+
*/
|
|
24
|
+
sortedZipKeys?: string[];
|
|
25
|
+
sortedZipPostings?: number[][];
|
|
17
26
|
/**
|
|
18
27
|
* Normalized Thai tambon names, parallel to `records` (same index).
|
|
19
28
|
* Used by the search ranker for exact/prefix match detection without
|
|
@@ -156,6 +165,12 @@ type RawData = {
|
|
|
156
165
|
* offending record's `id`, the field, and the actual `typeof` on the first bad
|
|
157
166
|
* field found. Does not filter soft-deleted rows first — validates every row as
|
|
158
167
|
* provided.
|
|
168
|
+
*
|
|
169
|
+
* IDs must be unique within each table (duplicates throw, they are not silently
|
|
170
|
+
* discarded — two suggestions sharing an id would resolve to the wrong record).
|
|
171
|
+
* Referential integrity (amphure→province, tambon→amphure) is intentionally NOT
|
|
172
|
+
* enforced: dangling references — including soft-deleted parents — are handled at
|
|
173
|
+
* build time, where the tambon is skipped and reported via `options.onSkip`.
|
|
159
174
|
*/
|
|
160
175
|
declare function validateRawData(data: RawData): void;
|
|
161
176
|
declare function buildThaiAddressIndex(data: RawData, options?: BuildIndexOptions): TrigramIndex;
|
|
@@ -164,9 +179,22 @@ declare function buildThaiAddressIndex(data: RawData, options?: BuildIndexOption
|
|
|
164
179
|
* Look up records by zip code (exact or prefix match). Exact matches are
|
|
165
180
|
* returned before prefix matches; ties are ordered by ascending zip code.
|
|
166
181
|
*
|
|
182
|
+
* Uses the build-time `sortedZipKeys`/`sortedZipPostings` arrays with a
|
|
183
|
+
* binary search for the first key ≥ the query, then walks the contiguous
|
|
184
|
+
* prefix range — O(log n_zips + matches). Ascending key order places an
|
|
185
|
+
* exact match before any longer prefix match automatically, so no explicit
|
|
186
|
+
* exact-first sort is needed. Indexes without the sorted arrays (older
|
|
187
|
+
* shape, hand-built) fall back to the original O(n_zips) scan + sort.
|
|
188
|
+
*
|
|
189
|
+
* Returned records are shallow copies: mutating them cannot corrupt the
|
|
190
|
+
* shared index (see `searchThaiAddress`).
|
|
191
|
+
*
|
|
167
192
|
* Unlike `searchThaiAddress`, results are NOT capped by `options.limit` —
|
|
168
193
|
* only by `options.zipLimit` (defaults to `Infinity`), since a zip code can
|
|
169
194
|
* legitimately map to dozens of tambons (e.g. `45000` → 33 records).
|
|
195
|
+
*
|
|
196
|
+
* Malformed runtime input (non-string `zip`, missing index shape) returns
|
|
197
|
+
* `[]` rather than crashing deep inside the library.
|
|
170
198
|
*/
|
|
171
199
|
declare function lookupByZipCode(index: TrigramIndex, zip: string, options?: SearchOptions): ThaiAddressRecord[];
|
|
172
200
|
declare function searchThaiAddress(index: TrigramIndex, query: string, options?: SearchOptions): ThaiAddressRecord[];
|
|
@@ -174,13 +202,15 @@ declare function searchThaiAddress(index: TrigramIndex, query: string, options?:
|
|
|
174
202
|
/**
|
|
175
203
|
* All provinces present in the index, deduplicated and sorted by Thai name.
|
|
176
204
|
* Backed by `index.byProvince` (O(unique provinces)); falls back to scanning
|
|
177
|
-
* `index.records` if an older-shaped index lacks that map.
|
|
205
|
+
* `index.records` if an older-shaped index lacks that map. A missing/junk
|
|
206
|
+
* index returns `[]` rather than throwing.
|
|
178
207
|
*/
|
|
179
208
|
declare function listProvinces(index: TrigramIndex): ProvinceSummary[];
|
|
180
209
|
/**
|
|
181
210
|
* Amphures (districts) within a given province, deduplicated and sorted by
|
|
182
211
|
* Thai name. Unknown `provinceId` returns `[]`. Backed by `index.byProvince`;
|
|
183
212
|
* falls back to scanning `index.records` if an older-shaped index lacks it.
|
|
213
|
+
* A missing/junk index returns `[]` rather than throwing.
|
|
184
214
|
*/
|
|
185
215
|
declare function listAmphures(index: TrigramIndex, provinceId: number): AmphureSummary[];
|
|
186
216
|
/**
|
|
@@ -188,7 +218,8 @@ declare function listAmphures(index: TrigramIndex, provinceId: number): AmphureS
|
|
|
188
218
|
* Unknown `amphureId` returns `[]`. Backed by `index.byAmphure`; falls back
|
|
189
219
|
* to scanning `index.records` if an older-shaped index lacks it. Each
|
|
190
220
|
* `byAmphure` entry already maps to exactly one tambon record, so no
|
|
191
|
-
* deduplication is needed here.
|
|
221
|
+
* deduplication is needed here. A missing/junk index returns `[]` rather
|
|
222
|
+
* than throwing.
|
|
192
223
|
*/
|
|
193
224
|
declare function listTambons(index: TrigramIndex, amphureId: number): TambonSummary[];
|
|
194
225
|
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,15 @@ type TrigramIndex = {
|
|
|
14
14
|
map: Map<string, Set<number>>;
|
|
15
15
|
records: ThaiAddressRecord[];
|
|
16
16
|
zipIndex: Map<string, number[]>;
|
|
17
|
+
/**
|
|
18
|
+
* Zip codes of `zipIndex` in ascending lexical order, with postings
|
|
19
|
+
* parallel in `sortedZipPostings`. Used by `lookupByZipCode` for
|
|
20
|
+
* O(log n + matches) prefix lookup instead of scanning every zip code.
|
|
21
|
+
* Optional: older-shaped or hand-built indexes without these fields fall
|
|
22
|
+
* back to the O(n_zips) scan.
|
|
23
|
+
*/
|
|
24
|
+
sortedZipKeys?: string[];
|
|
25
|
+
sortedZipPostings?: number[][];
|
|
17
26
|
/**
|
|
18
27
|
* Normalized Thai tambon names, parallel to `records` (same index).
|
|
19
28
|
* Used by the search ranker for exact/prefix match detection without
|
|
@@ -156,6 +165,12 @@ type RawData = {
|
|
|
156
165
|
* offending record's `id`, the field, and the actual `typeof` on the first bad
|
|
157
166
|
* field found. Does not filter soft-deleted rows first — validates every row as
|
|
158
167
|
* provided.
|
|
168
|
+
*
|
|
169
|
+
* IDs must be unique within each table (duplicates throw, they are not silently
|
|
170
|
+
* discarded — two suggestions sharing an id would resolve to the wrong record).
|
|
171
|
+
* Referential integrity (amphure→province, tambon→amphure) is intentionally NOT
|
|
172
|
+
* enforced: dangling references — including soft-deleted parents — are handled at
|
|
173
|
+
* build time, where the tambon is skipped and reported via `options.onSkip`.
|
|
159
174
|
*/
|
|
160
175
|
declare function validateRawData(data: RawData): void;
|
|
161
176
|
declare function buildThaiAddressIndex(data: RawData, options?: BuildIndexOptions): TrigramIndex;
|
|
@@ -164,9 +179,22 @@ declare function buildThaiAddressIndex(data: RawData, options?: BuildIndexOption
|
|
|
164
179
|
* Look up records by zip code (exact or prefix match). Exact matches are
|
|
165
180
|
* returned before prefix matches; ties are ordered by ascending zip code.
|
|
166
181
|
*
|
|
182
|
+
* Uses the build-time `sortedZipKeys`/`sortedZipPostings` arrays with a
|
|
183
|
+
* binary search for the first key ≥ the query, then walks the contiguous
|
|
184
|
+
* prefix range — O(log n_zips + matches). Ascending key order places an
|
|
185
|
+
* exact match before any longer prefix match automatically, so no explicit
|
|
186
|
+
* exact-first sort is needed. Indexes without the sorted arrays (older
|
|
187
|
+
* shape, hand-built) fall back to the original O(n_zips) scan + sort.
|
|
188
|
+
*
|
|
189
|
+
* Returned records are shallow copies: mutating them cannot corrupt the
|
|
190
|
+
* shared index (see `searchThaiAddress`).
|
|
191
|
+
*
|
|
167
192
|
* Unlike `searchThaiAddress`, results are NOT capped by `options.limit` —
|
|
168
193
|
* only by `options.zipLimit` (defaults to `Infinity`), since a zip code can
|
|
169
194
|
* legitimately map to dozens of tambons (e.g. `45000` → 33 records).
|
|
195
|
+
*
|
|
196
|
+
* Malformed runtime input (non-string `zip`, missing index shape) returns
|
|
197
|
+
* `[]` rather than crashing deep inside the library.
|
|
170
198
|
*/
|
|
171
199
|
declare function lookupByZipCode(index: TrigramIndex, zip: string, options?: SearchOptions): ThaiAddressRecord[];
|
|
172
200
|
declare function searchThaiAddress(index: TrigramIndex, query: string, options?: SearchOptions): ThaiAddressRecord[];
|
|
@@ -174,13 +202,15 @@ declare function searchThaiAddress(index: TrigramIndex, query: string, options?:
|
|
|
174
202
|
/**
|
|
175
203
|
* All provinces present in the index, deduplicated and sorted by Thai name.
|
|
176
204
|
* Backed by `index.byProvince` (O(unique provinces)); falls back to scanning
|
|
177
|
-
* `index.records` if an older-shaped index lacks that map.
|
|
205
|
+
* `index.records` if an older-shaped index lacks that map. A missing/junk
|
|
206
|
+
* index returns `[]` rather than throwing.
|
|
178
207
|
*/
|
|
179
208
|
declare function listProvinces(index: TrigramIndex): ProvinceSummary[];
|
|
180
209
|
/**
|
|
181
210
|
* Amphures (districts) within a given province, deduplicated and sorted by
|
|
182
211
|
* Thai name. Unknown `provinceId` returns `[]`. Backed by `index.byProvince`;
|
|
183
212
|
* falls back to scanning `index.records` if an older-shaped index lacks it.
|
|
213
|
+
* A missing/junk index returns `[]` rather than throwing.
|
|
184
214
|
*/
|
|
185
215
|
declare function listAmphures(index: TrigramIndex, provinceId: number): AmphureSummary[];
|
|
186
216
|
/**
|
|
@@ -188,7 +218,8 @@ declare function listAmphures(index: TrigramIndex, provinceId: number): AmphureS
|
|
|
188
218
|
* Unknown `amphureId` returns `[]`. Backed by `index.byAmphure`; falls back
|
|
189
219
|
* to scanning `index.records` if an older-shaped index lacks it. Each
|
|
190
220
|
* `byAmphure` entry already maps to exactly one tambon record, so no
|
|
191
|
-
* deduplication is needed here.
|
|
221
|
+
* deduplication is needed here. A missing/junk index returns `[]` rather
|
|
222
|
+
* than throwing.
|
|
192
223
|
*/
|
|
193
224
|
declare function listTambons(index: TrigramIndex, amphureId: number): TambonSummary[];
|
|
194
225
|
|
package/dist/index.js
CHANGED
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
var TONE_MARKS = /[่-๋็์]/g;
|
|
3
3
|
var ADDRESS_PREFIXES = /^(จังหวัด|อำเภอ|ตำบล|แขวง|เขต|จ\.|อ\.|ต\.|ข\.)/;
|
|
4
4
|
function normalizeThaiAddressText(input) {
|
|
5
|
-
if (
|
|
6
|
-
|
|
5
|
+
if (typeof input !== "string") {
|
|
6
|
+
throw new TypeError(
|
|
7
|
+
`[thaizip] normalizeThaiAddressText expected string, got ${input === null ? "null" : typeof input}`
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
if (input.length === 0) return "";
|
|
11
|
+
let text = input.normalize("NFC").trim();
|
|
7
12
|
text = text.replace(ADDRESS_PREFIXES, "").trim();
|
|
8
13
|
text = text.replace(TONE_MARKS, "");
|
|
9
14
|
text = text.toLowerCase();
|
|
@@ -31,24 +36,57 @@ function extractTrigramsNormalized(text) {
|
|
|
31
36
|
function typeErr(table, id, field, expected, value) {
|
|
32
37
|
return new TypeError(`[thaizip] ${table} ${id}: expected ${expected} for ${field}, got ${typeof value}`);
|
|
33
38
|
}
|
|
39
|
+
function typeName(value) {
|
|
40
|
+
return value === null ? "null" : typeof value;
|
|
41
|
+
}
|
|
34
42
|
function validateRawData(data) {
|
|
43
|
+
if (data === null || typeof data !== "object") {
|
|
44
|
+
throw new TypeError(`[thaizip] validateRawData expected a RawData object, got ${typeName(data)}`);
|
|
45
|
+
}
|
|
35
46
|
const { provinces, amphures, tambons } = data;
|
|
47
|
+
if (!Array.isArray(provinces)) {
|
|
48
|
+
throw new TypeError(`[thaizip] RawData.provinces must be an array, got ${typeName(provinces)}`);
|
|
49
|
+
}
|
|
50
|
+
if (!Array.isArray(amphures)) {
|
|
51
|
+
throw new TypeError(`[thaizip] RawData.amphures must be an array, got ${typeName(amphures)}`);
|
|
52
|
+
}
|
|
53
|
+
if (!Array.isArray(tambons)) {
|
|
54
|
+
throw new TypeError(`[thaizip] RawData.tambons must be an array, got ${typeName(tambons)}`);
|
|
55
|
+
}
|
|
56
|
+
const seenProvinceIds = /* @__PURE__ */ new Set();
|
|
36
57
|
for (let i = 0; i < provinces.length; i++) {
|
|
37
58
|
const p = provinces[i];
|
|
59
|
+
if (p === null || typeof p !== "object") {
|
|
60
|
+
throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
|
|
61
|
+
}
|
|
38
62
|
if (typeof p.id !== "number") throw typeErr("province", p.id, "id", "number", p.id);
|
|
63
|
+
if (seenProvinceIds.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
|
|
64
|
+
seenProvinceIds.add(p.id);
|
|
39
65
|
if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
|
|
40
66
|
if (typeof p.name_en !== "string") throw typeErr("province", p.id, "name_en", "string", p.name_en);
|
|
41
67
|
}
|
|
68
|
+
const seenAmphureIds = /* @__PURE__ */ new Set();
|
|
42
69
|
for (let i = 0; i < amphures.length; i++) {
|
|
43
70
|
const a = amphures[i];
|
|
71
|
+
if (a === null || typeof a !== "object") {
|
|
72
|
+
throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
|
|
73
|
+
}
|
|
44
74
|
if (typeof a.id !== "number") throw typeErr("amphure", a.id, "id", "number", a.id);
|
|
75
|
+
if (seenAmphureIds.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
|
|
76
|
+
seenAmphureIds.add(a.id);
|
|
45
77
|
if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
|
|
46
78
|
if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
|
|
47
79
|
if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
|
|
48
80
|
}
|
|
81
|
+
const seenTambonIds = /* @__PURE__ */ new Set();
|
|
49
82
|
for (let i = 0; i < tambons.length; i++) {
|
|
50
83
|
const t = tambons[i];
|
|
84
|
+
if (t === null || typeof t !== "object") {
|
|
85
|
+
throw new TypeError(`[thaizip] tambon[${i}]: expected object, got ${typeName(t)}`);
|
|
86
|
+
}
|
|
51
87
|
if (typeof t.id !== "number") throw typeErr("tambon", t.id, "id", "number", t.id);
|
|
88
|
+
if (seenTambonIds.has(t.id)) throw new TypeError(`[thaizip] duplicate tambon id: ${t.id}`);
|
|
89
|
+
seenTambonIds.add(t.id);
|
|
52
90
|
if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
|
|
53
91
|
if (typeof t.name_en !== "string") throw typeErr("tambon", t.id, "name_en", "string", t.name_en);
|
|
54
92
|
const zipType = typeof t.zip_code;
|
|
@@ -145,7 +183,9 @@ function buildThaiAddressIndex(data, options) {
|
|
|
145
183
|
addTrigrams(map, provTrigrams.get(province.id), idx);
|
|
146
184
|
addTrigrams(map, ampTrigrams.get(amphure.id), idx);
|
|
147
185
|
}
|
|
148
|
-
|
|
186
|
+
const sortedZipKeys = [...zipIndex.keys()].sort();
|
|
187
|
+
const sortedZipPostings = sortedZipKeys.map((z) => zipIndex.get(z));
|
|
188
|
+
return { map, records, zipIndex, sortedZipKeys, sortedZipPostings, normTambon, normTambonEn, byProvince, byAmphure };
|
|
149
189
|
}
|
|
150
190
|
|
|
151
191
|
// src/core/romanize.ts
|
|
@@ -274,19 +314,40 @@ function sortZipMatches(matches, exactZip) {
|
|
|
274
314
|
});
|
|
275
315
|
return matches;
|
|
276
316
|
}
|
|
317
|
+
function normalizeLimit(value) {
|
|
318
|
+
if (Number.isNaN(value) || value === -Infinity) return 0;
|
|
319
|
+
if (value === Infinity) return value;
|
|
320
|
+
return Math.max(0, Math.floor(value));
|
|
321
|
+
}
|
|
277
322
|
function lookupByZipCode(index, zip, options) {
|
|
278
|
-
if (!index || !zip) return [];
|
|
323
|
+
if (!index || !index.zipIndex || !index.records || typeof zip !== "string") return [];
|
|
279
324
|
const normalized = zip.trim();
|
|
280
325
|
if (!ZIP_CODE_RE.test(normalized) || normalized.length < 2) return [];
|
|
281
|
-
const zipLimit = options?.zipLimit ?? Infinity;
|
|
326
|
+
const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
|
|
282
327
|
const matches = [];
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
328
|
+
const keys = index.sortedZipKeys;
|
|
329
|
+
const postings = index.sortedZipPostings;
|
|
330
|
+
if (keys !== void 0 && postings !== void 0) {
|
|
331
|
+
let lo = 0;
|
|
332
|
+
let hi = keys.length;
|
|
333
|
+
while (lo < hi) {
|
|
334
|
+
const mid = lo + hi >> 1;
|
|
335
|
+
if (keys[mid] < normalized) lo = mid + 1;
|
|
336
|
+
else hi = mid;
|
|
337
|
+
}
|
|
338
|
+
for (let i = lo; i < keys.length && keys[i].startsWith(normalized); i++) {
|
|
339
|
+
const indices = postings[i];
|
|
340
|
+
for (let j = 0; j < indices.length; j++) matches.push(index.records[indices[j]]);
|
|
341
|
+
}
|
|
342
|
+
} else {
|
|
343
|
+
for (const [z, indices] of index.zipIndex) {
|
|
344
|
+
if (z.startsWith(normalized)) {
|
|
345
|
+
for (const idx of indices) matches.push(index.records[idx]);
|
|
346
|
+
}
|
|
286
347
|
}
|
|
348
|
+
sortZipMatches(matches, normalized);
|
|
287
349
|
}
|
|
288
|
-
|
|
289
|
-
return matches.slice(0, zipLimit);
|
|
350
|
+
return matches.slice(0, zipLimit).map((record) => ({ ...record }));
|
|
290
351
|
}
|
|
291
352
|
function rankAgainstName(name, query) {
|
|
292
353
|
if (name === void 0 || name.length === 0) return 0;
|
|
@@ -301,10 +362,20 @@ function computeMatchRank(index, idx, query) {
|
|
|
301
362
|
const en = rankAgainstName(index.normTambonEn?.[idx], query);
|
|
302
363
|
return en > th ? en : th;
|
|
303
364
|
}
|
|
365
|
+
var scratchByIndex = /* @__PURE__ */ new WeakMap();
|
|
366
|
+
function getScratch(index) {
|
|
367
|
+
const n = index.records.length;
|
|
368
|
+
let s = scratchByIndex.get(index);
|
|
369
|
+
if (s === void 0 || s.counts.length < n) {
|
|
370
|
+
s = { counts: new Uint32Array(n), touched: new Int32Array(n) };
|
|
371
|
+
scratchByIndex.set(index, s);
|
|
372
|
+
}
|
|
373
|
+
return s;
|
|
374
|
+
}
|
|
304
375
|
function searchThaiAddress(index, query, options) {
|
|
305
|
-
const limit = options?.limit ?? 10;
|
|
376
|
+
const limit = normalizeLimit(options?.limit ?? 10);
|
|
306
377
|
const threshold = options?.threshold ?? 0.4;
|
|
307
|
-
if (!index || !query) return [];
|
|
378
|
+
if (!index || !index.map || !index.records || typeof query !== "string") return [];
|
|
308
379
|
if (query.length > 1e3) return [];
|
|
309
380
|
const normalized = normalizeThaiAddressText(query);
|
|
310
381
|
if (normalized.length === 0) return [];
|
|
@@ -318,17 +389,22 @@ function searchThaiAddress(index, query, options) {
|
|
|
318
389
|
if (normalized.length < 3) return [];
|
|
319
390
|
const searchText = options?.romanizationAliases !== false && HAS_LATIN_RE.test(normalized) ? applyRomanizationAliases(normalized) : normalized;
|
|
320
391
|
const queryTrigrams = extractTrigramsNormalized(searchText);
|
|
321
|
-
const
|
|
392
|
+
const { counts, touched } = getScratch(index);
|
|
393
|
+
let touchedLen = 0;
|
|
322
394
|
for (const trigram of queryTrigrams) {
|
|
323
395
|
const candidates = index.map.get(trigram);
|
|
324
396
|
if (!candidates) continue;
|
|
325
397
|
for (const idx of candidates) {
|
|
326
|
-
|
|
398
|
+
if (counts[idx] === 0) touched[touchedLen++] = idx;
|
|
399
|
+
counts[idx]++;
|
|
327
400
|
}
|
|
328
401
|
}
|
|
329
402
|
const scored = [];
|
|
330
|
-
|
|
331
|
-
|
|
403
|
+
const totalTrigrams = queryTrigrams.size;
|
|
404
|
+
for (let i = 0; i < touchedLen; i++) {
|
|
405
|
+
const idx = touched[i];
|
|
406
|
+
const score = counts[idx] / totalTrigrams;
|
|
407
|
+
counts[idx] = 0;
|
|
332
408
|
if (score >= threshold) {
|
|
333
409
|
scored.push({ idx, score, matchRank: computeMatchRank(index, idx, searchText) });
|
|
334
410
|
}
|
|
@@ -346,7 +422,7 @@ function searchThaiAddress(index, query, options) {
|
|
|
346
422
|
const rb = index.records[b.idx];
|
|
347
423
|
return TH_COLLATOR.compare(ra.provinceNameTh, rb.provinceNameTh) || TH_COLLATOR.compare(ra.amphureNameTh, rb.amphureNameTh) || TH_COLLATOR.compare(ra.tambonNameTh, rb.tambonNameTh);
|
|
348
424
|
});
|
|
349
|
-
return window.slice(0, limit).map(({ idx }) => index.records[idx]);
|
|
425
|
+
return window.slice(0, limit).map(({ idx }) => ({ ...index.records[idx] }));
|
|
350
426
|
}
|
|
351
427
|
|
|
352
428
|
// src/core/enumerate.ts
|
|
@@ -355,6 +431,7 @@ function byNameTh(a, b) {
|
|
|
355
431
|
return thCollator.compare(a.nameTh, b.nameTh);
|
|
356
432
|
}
|
|
357
433
|
function listProvinces(index) {
|
|
434
|
+
if (!index || !index.records) return [];
|
|
358
435
|
const seen = /* @__PURE__ */ new Map();
|
|
359
436
|
if (index.byProvince) {
|
|
360
437
|
for (const [provinceId, recordIndices] of index.byProvince) {
|
|
@@ -372,6 +449,7 @@ function listProvinces(index) {
|
|
|
372
449
|
return Array.from(seen.values()).sort(byNameTh);
|
|
373
450
|
}
|
|
374
451
|
function listAmphures(index, provinceId) {
|
|
452
|
+
if (!index || !index.records) return [];
|
|
375
453
|
const seen = /* @__PURE__ */ new Map();
|
|
376
454
|
const addFrom = (r) => {
|
|
377
455
|
if (!seen.has(r.amphureId)) {
|
|
@@ -395,6 +473,7 @@ function listAmphures(index, provinceId) {
|
|
|
395
473
|
return Array.from(seen.values()).sort(byNameTh);
|
|
396
474
|
}
|
|
397
475
|
function listTambons(index, amphureId) {
|
|
476
|
+
if (!index || !index.records) return [];
|
|
398
477
|
const toSummary = (r) => ({
|
|
399
478
|
id: r.tambonId,
|
|
400
479
|
nameTh: r.tambonNameTh,
|