thaizip 0.7.2 → 0.7.4
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 +15 -1
- package/dist/data.cjs +53 -8
- package/dist/data.d.cts +9 -0
- package/dist/data.d.ts +9 -0
- package/dist/data.js +53 -8
- package/dist/index.cjs +105 -25
- package/dist/index.d.cts +34 -2
- package/dist/index.d.ts +34 -2
- package/dist/index.js +105 -25
- package/dist/react.cjs +71 -39
- package/dist/react.d.cts +9 -0
- package/dist/react.d.ts +9 -0
- package/dist/react.js +71 -39
- package/package.json +11 -5
- 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
|
|
@@ -268,27 +308,44 @@ function compareZip(a, b) {
|
|
|
268
308
|
if (a > b) return 1;
|
|
269
309
|
return 0;
|
|
270
310
|
}
|
|
271
|
-
function
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return compareZip(a.zipCode, b.zipCode);
|
|
276
|
-
});
|
|
277
|
-
return matches;
|
|
311
|
+
function normalizeLimit(value) {
|
|
312
|
+
if (Number.isNaN(value) || value === -Infinity) return 0;
|
|
313
|
+
if (value === Infinity) return value;
|
|
314
|
+
return Math.max(0, Math.floor(value));
|
|
278
315
|
}
|
|
279
316
|
function lookupByZipCode(index, zip, options) {
|
|
280
|
-
if (!index || !zip) return [];
|
|
317
|
+
if (!index || !index.zipIndex || !index.records || typeof zip !== "string") return [];
|
|
318
|
+
if (zip.length > 1e3) return [];
|
|
319
|
+
const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
|
|
320
|
+
if (zipLimit === 0) return [];
|
|
281
321
|
const normalized = zip.trim();
|
|
282
322
|
if (!ZIP_CODE_RE.test(normalized) || normalized.length < 2) return [];
|
|
283
|
-
const zipLimit = options?.zipLimit ?? Infinity;
|
|
284
323
|
const matches = [];
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
324
|
+
const keys = index.sortedZipKeys;
|
|
325
|
+
const postings = index.sortedZipPostings;
|
|
326
|
+
if (keys !== void 0 && postings !== void 0) {
|
|
327
|
+
let lo = 0;
|
|
328
|
+
let hi = keys.length;
|
|
329
|
+
while (lo < hi) {
|
|
330
|
+
const mid = lo + hi >> 1;
|
|
331
|
+
if (keys[mid] < normalized) lo = mid + 1;
|
|
332
|
+
else hi = mid;
|
|
333
|
+
}
|
|
334
|
+
for (let i = lo; i < keys.length && keys[i].startsWith(normalized) && matches.length < zipLimit; i++) {
|
|
335
|
+
const indices = postings[i];
|
|
336
|
+
for (let j = 0; j < indices.length && matches.length < zipLimit; j++) matches.push(index.records[indices[j]]);
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
const matchingKeys = [...index.zipIndex.keys()].filter((z) => z.startsWith(normalized)).sort(compareZip);
|
|
340
|
+
for (const z of matchingKeys) {
|
|
341
|
+
if (matches.length >= zipLimit) break;
|
|
342
|
+
for (const idx of index.zipIndex.get(z)) {
|
|
343
|
+
if (matches.length >= zipLimit) break;
|
|
344
|
+
matches.push(index.records[idx]);
|
|
345
|
+
}
|
|
288
346
|
}
|
|
289
347
|
}
|
|
290
|
-
|
|
291
|
-
return matches.slice(0, zipLimit);
|
|
348
|
+
return matches.slice(0, zipLimit).map((record) => ({ ...record }));
|
|
292
349
|
}
|
|
293
350
|
function rankAgainstName(name, query) {
|
|
294
351
|
if (name === void 0 || name.length === 0) return 0;
|
|
@@ -303,10 +360,20 @@ function computeMatchRank(index, idx, query) {
|
|
|
303
360
|
const en = rankAgainstName(index.normTambonEn?.[idx], query);
|
|
304
361
|
return en > th ? en : th;
|
|
305
362
|
}
|
|
363
|
+
var scratchByIndex = /* @__PURE__ */ new WeakMap();
|
|
364
|
+
function getScratch(index) {
|
|
365
|
+
const n = index.records.length;
|
|
366
|
+
let s = scratchByIndex.get(index);
|
|
367
|
+
if (s === void 0 || s.counts.length < n) {
|
|
368
|
+
s = { counts: new Uint32Array(n), touched: new Int32Array(n) };
|
|
369
|
+
scratchByIndex.set(index, s);
|
|
370
|
+
}
|
|
371
|
+
return s;
|
|
372
|
+
}
|
|
306
373
|
function searchThaiAddress(index, query, options) {
|
|
307
|
-
const limit = options?.limit ?? 10;
|
|
374
|
+
const limit = normalizeLimit(options?.limit ?? 10);
|
|
308
375
|
const threshold = options?.threshold ?? 0.4;
|
|
309
|
-
if (!index || !query) return [];
|
|
376
|
+
if (!index || !index.map || !index.records || typeof query !== "string") return [];
|
|
310
377
|
if (query.length > 1e3) return [];
|
|
311
378
|
const normalized = normalizeThaiAddressText(query);
|
|
312
379
|
if (normalized.length === 0) return [];
|
|
@@ -318,19 +385,25 @@ function searchThaiAddress(index, query, options) {
|
|
|
318
385
|
});
|
|
319
386
|
}
|
|
320
387
|
if (normalized.length < 3) return [];
|
|
388
|
+
if (limit === 0) return [];
|
|
321
389
|
const searchText = options?.romanizationAliases !== false && HAS_LATIN_RE.test(normalized) ? applyRomanizationAliases(normalized) : normalized;
|
|
322
390
|
const queryTrigrams = extractTrigramsNormalized(searchText);
|
|
323
|
-
const
|
|
391
|
+
const { counts, touched } = getScratch(index);
|
|
392
|
+
let touchedLen = 0;
|
|
324
393
|
for (const trigram of queryTrigrams) {
|
|
325
394
|
const candidates = index.map.get(trigram);
|
|
326
395
|
if (!candidates) continue;
|
|
327
396
|
for (const idx of candidates) {
|
|
328
|
-
|
|
397
|
+
if (counts[idx] === 0) touched[touchedLen++] = idx;
|
|
398
|
+
counts[idx]++;
|
|
329
399
|
}
|
|
330
400
|
}
|
|
331
401
|
const scored = [];
|
|
332
|
-
|
|
333
|
-
|
|
402
|
+
const totalTrigrams = queryTrigrams.size;
|
|
403
|
+
for (let i = 0; i < touchedLen; i++) {
|
|
404
|
+
const idx = touched[i];
|
|
405
|
+
const score = counts[idx] / totalTrigrams;
|
|
406
|
+
counts[idx] = 0;
|
|
334
407
|
if (score >= threshold) {
|
|
335
408
|
scored.push({ idx, score, matchRank: computeMatchRank(index, idx, searchText) });
|
|
336
409
|
}
|
|
@@ -339,7 +412,11 @@ function searchThaiAddress(index, query, options) {
|
|
|
339
412
|
if (b.score !== a.score) return b.score - a.score;
|
|
340
413
|
return b.matchRank - a.matchRank;
|
|
341
414
|
});
|
|
342
|
-
|
|
415
|
+
let windowSize = Math.min(limit, scored.length);
|
|
416
|
+
const boundary = scored[windowSize - 1];
|
|
417
|
+
while (windowSize < scored.length && scored[windowSize].score === boundary.score && scored[windowSize].matchRank === boundary.matchRank) {
|
|
418
|
+
windowSize++;
|
|
419
|
+
}
|
|
343
420
|
const window = scored.slice(0, windowSize);
|
|
344
421
|
window.sort((a, b) => {
|
|
345
422
|
if (b.score !== a.score) return b.score - a.score;
|
|
@@ -348,7 +425,7 @@ function searchThaiAddress(index, query, options) {
|
|
|
348
425
|
const rb = index.records[b.idx];
|
|
349
426
|
return TH_COLLATOR.compare(ra.provinceNameTh, rb.provinceNameTh) || TH_COLLATOR.compare(ra.amphureNameTh, rb.amphureNameTh) || TH_COLLATOR.compare(ra.tambonNameTh, rb.tambonNameTh);
|
|
350
427
|
});
|
|
351
|
-
return window.slice(0, limit).map(({ idx }) => index.records[idx]);
|
|
428
|
+
return window.slice(0, limit).map(({ idx }) => ({ ...index.records[idx] }));
|
|
352
429
|
}
|
|
353
430
|
|
|
354
431
|
// src/core/enumerate.ts
|
|
@@ -357,6 +434,7 @@ function byNameTh(a, b) {
|
|
|
357
434
|
return thCollator.compare(a.nameTh, b.nameTh);
|
|
358
435
|
}
|
|
359
436
|
function listProvinces(index) {
|
|
437
|
+
if (!index || !index.records) return [];
|
|
360
438
|
const seen = /* @__PURE__ */ new Map();
|
|
361
439
|
if (index.byProvince) {
|
|
362
440
|
for (const [provinceId, recordIndices] of index.byProvince) {
|
|
@@ -374,6 +452,7 @@ function listProvinces(index) {
|
|
|
374
452
|
return Array.from(seen.values()).sort(byNameTh);
|
|
375
453
|
}
|
|
376
454
|
function listAmphures(index, provinceId) {
|
|
455
|
+
if (!index || !index.records) return [];
|
|
377
456
|
const seen = /* @__PURE__ */ new Map();
|
|
378
457
|
const addFrom = (r) => {
|
|
379
458
|
if (!seen.has(r.amphureId)) {
|
|
@@ -397,6 +476,7 @@ function listAmphures(index, provinceId) {
|
|
|
397
476
|
return Array.from(seen.values()).sort(byNameTh);
|
|
398
477
|
}
|
|
399
478
|
function listTambons(index, amphureId) {
|
|
479
|
+
if (!index || !index.records) return [];
|
|
400
480
|
const toSummary = (r) => ({
|
|
401
481
|
id: r.tambonId,
|
|
402
482
|
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,23 @@ 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) scan and sort matching keys before collecting records.
|
|
188
|
+
* Both paths stop collecting when `zipLimit` is reached.
|
|
189
|
+
*
|
|
190
|
+
* Returned records are shallow copies: mutating them cannot corrupt the
|
|
191
|
+
* shared index (see `searchThaiAddress`).
|
|
192
|
+
*
|
|
167
193
|
* Unlike `searchThaiAddress`, results are NOT capped by `options.limit` —
|
|
168
194
|
* only by `options.zipLimit` (defaults to `Infinity`), since a zip code can
|
|
169
195
|
* legitimately map to dozens of tambons (e.g. `45000` → 33 records).
|
|
196
|
+
*
|
|
197
|
+
* Malformed runtime input (non-string `zip`, missing index shape) and raw
|
|
198
|
+
* strings longer than 1000 characters return `[]`.
|
|
170
199
|
*/
|
|
171
200
|
declare function lookupByZipCode(index: TrigramIndex, zip: string, options?: SearchOptions): ThaiAddressRecord[];
|
|
172
201
|
declare function searchThaiAddress(index: TrigramIndex, query: string, options?: SearchOptions): ThaiAddressRecord[];
|
|
@@ -174,13 +203,15 @@ declare function searchThaiAddress(index: TrigramIndex, query: string, options?:
|
|
|
174
203
|
/**
|
|
175
204
|
* All provinces present in the index, deduplicated and sorted by Thai name.
|
|
176
205
|
* Backed by `index.byProvince` (O(unique provinces)); falls back to scanning
|
|
177
|
-
* `index.records` if an older-shaped index lacks that map.
|
|
206
|
+
* `index.records` if an older-shaped index lacks that map. A missing/junk
|
|
207
|
+
* index returns `[]` rather than throwing.
|
|
178
208
|
*/
|
|
179
209
|
declare function listProvinces(index: TrigramIndex): ProvinceSummary[];
|
|
180
210
|
/**
|
|
181
211
|
* Amphures (districts) within a given province, deduplicated and sorted by
|
|
182
212
|
* Thai name. Unknown `provinceId` returns `[]`. Backed by `index.byProvince`;
|
|
183
213
|
* falls back to scanning `index.records` if an older-shaped index lacks it.
|
|
214
|
+
* A missing/junk index returns `[]` rather than throwing.
|
|
184
215
|
*/
|
|
185
216
|
declare function listAmphures(index: TrigramIndex, provinceId: number): AmphureSummary[];
|
|
186
217
|
/**
|
|
@@ -188,7 +219,8 @@ declare function listAmphures(index: TrigramIndex, provinceId: number): AmphureS
|
|
|
188
219
|
* Unknown `amphureId` returns `[]`. Backed by `index.byAmphure`; falls back
|
|
189
220
|
* to scanning `index.records` if an older-shaped index lacks it. Each
|
|
190
221
|
* `byAmphure` entry already maps to exactly one tambon record, so no
|
|
191
|
-
* deduplication is needed here.
|
|
222
|
+
* deduplication is needed here. A missing/junk index returns `[]` rather
|
|
223
|
+
* than throwing.
|
|
192
224
|
*/
|
|
193
225
|
declare function listTambons(index: TrigramIndex, amphureId: number): TambonSummary[];
|
|
194
226
|
|
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,23 @@ 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) scan and sort matching keys before collecting records.
|
|
188
|
+
* Both paths stop collecting when `zipLimit` is reached.
|
|
189
|
+
*
|
|
190
|
+
* Returned records are shallow copies: mutating them cannot corrupt the
|
|
191
|
+
* shared index (see `searchThaiAddress`).
|
|
192
|
+
*
|
|
167
193
|
* Unlike `searchThaiAddress`, results are NOT capped by `options.limit` —
|
|
168
194
|
* only by `options.zipLimit` (defaults to `Infinity`), since a zip code can
|
|
169
195
|
* legitimately map to dozens of tambons (e.g. `45000` → 33 records).
|
|
196
|
+
*
|
|
197
|
+
* Malformed runtime input (non-string `zip`, missing index shape) and raw
|
|
198
|
+
* strings longer than 1000 characters return `[]`.
|
|
170
199
|
*/
|
|
171
200
|
declare function lookupByZipCode(index: TrigramIndex, zip: string, options?: SearchOptions): ThaiAddressRecord[];
|
|
172
201
|
declare function searchThaiAddress(index: TrigramIndex, query: string, options?: SearchOptions): ThaiAddressRecord[];
|
|
@@ -174,13 +203,15 @@ declare function searchThaiAddress(index: TrigramIndex, query: string, options?:
|
|
|
174
203
|
/**
|
|
175
204
|
* All provinces present in the index, deduplicated and sorted by Thai name.
|
|
176
205
|
* Backed by `index.byProvince` (O(unique provinces)); falls back to scanning
|
|
177
|
-
* `index.records` if an older-shaped index lacks that map.
|
|
206
|
+
* `index.records` if an older-shaped index lacks that map. A missing/junk
|
|
207
|
+
* index returns `[]` rather than throwing.
|
|
178
208
|
*/
|
|
179
209
|
declare function listProvinces(index: TrigramIndex): ProvinceSummary[];
|
|
180
210
|
/**
|
|
181
211
|
* Amphures (districts) within a given province, deduplicated and sorted by
|
|
182
212
|
* Thai name. Unknown `provinceId` returns `[]`. Backed by `index.byProvince`;
|
|
183
213
|
* falls back to scanning `index.records` if an older-shaped index lacks it.
|
|
214
|
+
* A missing/junk index returns `[]` rather than throwing.
|
|
184
215
|
*/
|
|
185
216
|
declare function listAmphures(index: TrigramIndex, provinceId: number): AmphureSummary[];
|
|
186
217
|
/**
|
|
@@ -188,7 +219,8 @@ declare function listAmphures(index: TrigramIndex, provinceId: number): AmphureS
|
|
|
188
219
|
* Unknown `amphureId` returns `[]`. Backed by `index.byAmphure`; falls back
|
|
189
220
|
* to scanning `index.records` if an older-shaped index lacks it. Each
|
|
190
221
|
* `byAmphure` entry already maps to exactly one tambon record, so no
|
|
191
|
-
* deduplication is needed here.
|
|
222
|
+
* deduplication is needed here. A missing/junk index returns `[]` rather
|
|
223
|
+
* than throwing.
|
|
192
224
|
*/
|
|
193
225
|
declare function listTambons(index: TrigramIndex, amphureId: number): TambonSummary[];
|
|
194
226
|
|
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
|
|
@@ -266,27 +306,44 @@ function compareZip(a, b) {
|
|
|
266
306
|
if (a > b) return 1;
|
|
267
307
|
return 0;
|
|
268
308
|
}
|
|
269
|
-
function
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
return compareZip(a.zipCode, b.zipCode);
|
|
274
|
-
});
|
|
275
|
-
return matches;
|
|
309
|
+
function normalizeLimit(value) {
|
|
310
|
+
if (Number.isNaN(value) || value === -Infinity) return 0;
|
|
311
|
+
if (value === Infinity) return value;
|
|
312
|
+
return Math.max(0, Math.floor(value));
|
|
276
313
|
}
|
|
277
314
|
function lookupByZipCode(index, zip, options) {
|
|
278
|
-
if (!index || !zip) return [];
|
|
315
|
+
if (!index || !index.zipIndex || !index.records || typeof zip !== "string") return [];
|
|
316
|
+
if (zip.length > 1e3) return [];
|
|
317
|
+
const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
|
|
318
|
+
if (zipLimit === 0) return [];
|
|
279
319
|
const normalized = zip.trim();
|
|
280
320
|
if (!ZIP_CODE_RE.test(normalized) || normalized.length < 2) return [];
|
|
281
|
-
const zipLimit = options?.zipLimit ?? Infinity;
|
|
282
321
|
const matches = [];
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
322
|
+
const keys = index.sortedZipKeys;
|
|
323
|
+
const postings = index.sortedZipPostings;
|
|
324
|
+
if (keys !== void 0 && postings !== void 0) {
|
|
325
|
+
let lo = 0;
|
|
326
|
+
let hi = keys.length;
|
|
327
|
+
while (lo < hi) {
|
|
328
|
+
const mid = lo + hi >> 1;
|
|
329
|
+
if (keys[mid] < normalized) lo = mid + 1;
|
|
330
|
+
else hi = mid;
|
|
331
|
+
}
|
|
332
|
+
for (let i = lo; i < keys.length && keys[i].startsWith(normalized) && matches.length < zipLimit; i++) {
|
|
333
|
+
const indices = postings[i];
|
|
334
|
+
for (let j = 0; j < indices.length && matches.length < zipLimit; j++) matches.push(index.records[indices[j]]);
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
const matchingKeys = [...index.zipIndex.keys()].filter((z) => z.startsWith(normalized)).sort(compareZip);
|
|
338
|
+
for (const z of matchingKeys) {
|
|
339
|
+
if (matches.length >= zipLimit) break;
|
|
340
|
+
for (const idx of index.zipIndex.get(z)) {
|
|
341
|
+
if (matches.length >= zipLimit) break;
|
|
342
|
+
matches.push(index.records[idx]);
|
|
343
|
+
}
|
|
286
344
|
}
|
|
287
345
|
}
|
|
288
|
-
|
|
289
|
-
return matches.slice(0, zipLimit);
|
|
346
|
+
return matches.slice(0, zipLimit).map((record) => ({ ...record }));
|
|
290
347
|
}
|
|
291
348
|
function rankAgainstName(name, query) {
|
|
292
349
|
if (name === void 0 || name.length === 0) return 0;
|
|
@@ -301,10 +358,20 @@ function computeMatchRank(index, idx, query) {
|
|
|
301
358
|
const en = rankAgainstName(index.normTambonEn?.[idx], query);
|
|
302
359
|
return en > th ? en : th;
|
|
303
360
|
}
|
|
361
|
+
var scratchByIndex = /* @__PURE__ */ new WeakMap();
|
|
362
|
+
function getScratch(index) {
|
|
363
|
+
const n = index.records.length;
|
|
364
|
+
let s = scratchByIndex.get(index);
|
|
365
|
+
if (s === void 0 || s.counts.length < n) {
|
|
366
|
+
s = { counts: new Uint32Array(n), touched: new Int32Array(n) };
|
|
367
|
+
scratchByIndex.set(index, s);
|
|
368
|
+
}
|
|
369
|
+
return s;
|
|
370
|
+
}
|
|
304
371
|
function searchThaiAddress(index, query, options) {
|
|
305
|
-
const limit = options?.limit ?? 10;
|
|
372
|
+
const limit = normalizeLimit(options?.limit ?? 10);
|
|
306
373
|
const threshold = options?.threshold ?? 0.4;
|
|
307
|
-
if (!index || !query) return [];
|
|
374
|
+
if (!index || !index.map || !index.records || typeof query !== "string") return [];
|
|
308
375
|
if (query.length > 1e3) return [];
|
|
309
376
|
const normalized = normalizeThaiAddressText(query);
|
|
310
377
|
if (normalized.length === 0) return [];
|
|
@@ -316,19 +383,25 @@ function searchThaiAddress(index, query, options) {
|
|
|
316
383
|
});
|
|
317
384
|
}
|
|
318
385
|
if (normalized.length < 3) return [];
|
|
386
|
+
if (limit === 0) return [];
|
|
319
387
|
const searchText = options?.romanizationAliases !== false && HAS_LATIN_RE.test(normalized) ? applyRomanizationAliases(normalized) : normalized;
|
|
320
388
|
const queryTrigrams = extractTrigramsNormalized(searchText);
|
|
321
|
-
const
|
|
389
|
+
const { counts, touched } = getScratch(index);
|
|
390
|
+
let touchedLen = 0;
|
|
322
391
|
for (const trigram of queryTrigrams) {
|
|
323
392
|
const candidates = index.map.get(trigram);
|
|
324
393
|
if (!candidates) continue;
|
|
325
394
|
for (const idx of candidates) {
|
|
326
|
-
|
|
395
|
+
if (counts[idx] === 0) touched[touchedLen++] = idx;
|
|
396
|
+
counts[idx]++;
|
|
327
397
|
}
|
|
328
398
|
}
|
|
329
399
|
const scored = [];
|
|
330
|
-
|
|
331
|
-
|
|
400
|
+
const totalTrigrams = queryTrigrams.size;
|
|
401
|
+
for (let i = 0; i < touchedLen; i++) {
|
|
402
|
+
const idx = touched[i];
|
|
403
|
+
const score = counts[idx] / totalTrigrams;
|
|
404
|
+
counts[idx] = 0;
|
|
332
405
|
if (score >= threshold) {
|
|
333
406
|
scored.push({ idx, score, matchRank: computeMatchRank(index, idx, searchText) });
|
|
334
407
|
}
|
|
@@ -337,7 +410,11 @@ function searchThaiAddress(index, query, options) {
|
|
|
337
410
|
if (b.score !== a.score) return b.score - a.score;
|
|
338
411
|
return b.matchRank - a.matchRank;
|
|
339
412
|
});
|
|
340
|
-
|
|
413
|
+
let windowSize = Math.min(limit, scored.length);
|
|
414
|
+
const boundary = scored[windowSize - 1];
|
|
415
|
+
while (windowSize < scored.length && scored[windowSize].score === boundary.score && scored[windowSize].matchRank === boundary.matchRank) {
|
|
416
|
+
windowSize++;
|
|
417
|
+
}
|
|
341
418
|
const window = scored.slice(0, windowSize);
|
|
342
419
|
window.sort((a, b) => {
|
|
343
420
|
if (b.score !== a.score) return b.score - a.score;
|
|
@@ -346,7 +423,7 @@ function searchThaiAddress(index, query, options) {
|
|
|
346
423
|
const rb = index.records[b.idx];
|
|
347
424
|
return TH_COLLATOR.compare(ra.provinceNameTh, rb.provinceNameTh) || TH_COLLATOR.compare(ra.amphureNameTh, rb.amphureNameTh) || TH_COLLATOR.compare(ra.tambonNameTh, rb.tambonNameTh);
|
|
348
425
|
});
|
|
349
|
-
return window.slice(0, limit).map(({ idx }) => index.records[idx]);
|
|
426
|
+
return window.slice(0, limit).map(({ idx }) => ({ ...index.records[idx] }));
|
|
350
427
|
}
|
|
351
428
|
|
|
352
429
|
// src/core/enumerate.ts
|
|
@@ -355,6 +432,7 @@ function byNameTh(a, b) {
|
|
|
355
432
|
return thCollator.compare(a.nameTh, b.nameTh);
|
|
356
433
|
}
|
|
357
434
|
function listProvinces(index) {
|
|
435
|
+
if (!index || !index.records) return [];
|
|
358
436
|
const seen = /* @__PURE__ */ new Map();
|
|
359
437
|
if (index.byProvince) {
|
|
360
438
|
for (const [provinceId, recordIndices] of index.byProvince) {
|
|
@@ -372,6 +450,7 @@ function listProvinces(index) {
|
|
|
372
450
|
return Array.from(seen.values()).sort(byNameTh);
|
|
373
451
|
}
|
|
374
452
|
function listAmphures(index, provinceId) {
|
|
453
|
+
if (!index || !index.records) return [];
|
|
375
454
|
const seen = /* @__PURE__ */ new Map();
|
|
376
455
|
const addFrom = (r) => {
|
|
377
456
|
if (!seen.has(r.amphureId)) {
|
|
@@ -395,6 +474,7 @@ function listAmphures(index, provinceId) {
|
|
|
395
474
|
return Array.from(seen.values()).sort(byNameTh);
|
|
396
475
|
}
|
|
397
476
|
function listTambons(index, amphureId) {
|
|
477
|
+
if (!index || !index.records) return [];
|
|
398
478
|
const toSummary = (r) => ({
|
|
399
479
|
id: r.tambonId,
|
|
400
480
|
nameTh: r.tambonNameTh,
|