thaizip 0.5.1 → 0.7.2

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/README.md CHANGED
@@ -1,285 +1,162 @@
1
1
  # thaizip
2
2
 
3
- ไลบรารี autocomplete ที่อยู่ไทยแบบ fuzzy search รวดเร็ว รองรับทั้ง Vanilla JS และ React
4
-
5
- - ค้นหาได้ทั้งภาษาไทย ภาษาอังกฤษ และรหัสไปรษณีย์
6
- - Fuzzy search — ไม่ต้องใส่วรรณยุกต์ก็ค้นหาได้ เช่น "ลาดพราว" เจอ "ลาดพร้าว"
7
- - ไม่มี dependency นอกจาก React (optional)
8
- - โหลดข้อมูลแบบ async — ไม่บล็อก initial bundle (~132 KB gzip)
9
- - รองรับ ESM และ CJS
10
-
11
- ---
12
-
13
- ## ติดตั้ง
3
+ Fast fuzzy autocomplete for Thai addresses — subdistrict, district, province, postal code. Thai and English input, zero runtime dependencies, React optional.
14
4
 
15
5
  ```bash
16
6
  npm install thaizip
17
7
  ```
18
8
 
19
- ถ้าใช้กับ React ต้องมี React >= 18 เป็น peer dependency อยู่แล้ว ไม่ต้องติดตั้งเพิ่ม
20
-
21
- ---
22
-
23
- ## การใช้งานพื้นฐาน (Vanilla JS / TypeScript)
9
+ | import path | contents |
10
+ |---|---|
11
+ | `thaizip` | core functions + types (no React code, 4.4 KB gzip) |
12
+ | `thaizip/react` | `useThaiAddressAutocomplete` (ships `"use client"`) |
13
+ | `thaizip/data` | `loadDefaultIndex`, `clearDefaultIndex` (132 KB gzip) |
24
14
 
25
- ### โหลด index และค้นหาที่อยู่
15
+ ## Quick start
26
16
 
27
17
  ```ts
28
18
  import { loadDefaultIndex } from 'thaizip/data'
29
- import { searchThaiAddress } from 'thaizip'
19
+ import { searchThaiAddress, formatThaiAddressSuggestion, resolveThaiAddress } from 'thaizip'
30
20
 
31
- // โหลด index ครั้งแรก ~200ms (cached หลังจากนั้น)
32
- const index = await loadDefaultIndex()
21
+ const index = await loadDefaultIndex() // ~40ms of synchronous work, cached after
33
22
 
34
- // ค้นหาด้วยชื่อตำบล/อำเภอ/จังหวัด
35
- const results = searchThaiAddress(index, 'ลาดพร้าว')
23
+ searchThaiAddress(index, 'ลาดพร้าว')
24
+ searchThaiAddress(index, 'bang rak')
25
+ searchThaiAddress(index, '10500')
36
26
 
37
- // ค้นหาด้วยรหัสไปรษณีย์
38
- const results2 = searchThaiAddress(index, '10900')
39
-
40
- // ค้นหาด้วยภาษาอังกฤษ
41
- const results3 = searchThaiAddress(index, 'chiang mai')
42
-
43
- // ⚠️ ไม่รองรับ: ค้นหาแบบรวม text + รหัสไปรษณีย์ในครั้งเดียว (เช่น "ลาดพร้าว 10900")
44
- // ให้ค้นหาด้วยชื่อสถานที่ หรือรหัสไปรษณีย์ แยกกัน
45
-
46
- console.log(results)
47
- // [{ tambonId, tambonNameTh, tambonNameEn, amphureId, amphureNameTh, ... zipCode }, ...]
27
+ formatThaiAddressSuggestion(record) // for the dropdown
28
+ formatThaiAddressSuggestion(record, { locale: 'en' })
29
+ resolveThaiAddress(record) // for saving
48
30
  ```
49
31
 
50
- ### ตัวเลือก (options)
32
+ Call `loadDefaultIndex()` at mount or route load — deferring it to the user's first keystroke costs ~2 dropped frames.
33
+
34
+ ## Search
51
35
 
52
36
  ```ts
53
- const results = searchThaiAddress(index, 'ลาดพร้าว', {
54
- limit: 5, // จำนวนผลลัพธ์สูงสุด (default: 10)
55
- threshold: 0.4, // ความแม่นยำขั้นต่ำ 0–1 (default: 0.4)
37
+ searchThaiAddress(index, query, {
38
+ limit: 10, // text queries only
39
+ threshold: 0.4, // match quality 0–1
40
+ zipLimit: Infinity, // all-digit queries — unlimited by default
41
+ romanizationAliases: true, // expand non-RTGS spellings
56
42
  })
57
43
  ```
58
44
 
59
- ### แปลงผลลัพธ์เป็นรูปแบบต่าง ๆ
45
+ Results rank by trigram score, then by how well the query matches the subdistrict's **own** name (exact → prefix → substring), then alphabetically. That second key is what keeps ตำบลลาดพร้าว above the other subdistricts that merely sit inside เขตลาดพร้าว.
60
46
 
61
- **`formatThaiAddressSuggestion`** — ใช้แสดงใน dropdown
47
+ > Combined text + zip in one query (`"ลาดพร้าว 10900"`) is not supported — search by name or zip separately.
62
48
 
63
- ```ts
64
- import { formatThaiAddressSuggestion } from 'thaizip'
65
-
66
- const suggestion = formatThaiAddressSuggestion(results[0])
67
- // {
68
- // id: '100101',
69
- // label: 'ลาดพร้าว > ลาดพร้าว > กรุงเทพมหานคร 10230',
70
- // tambon: 'ลาดพร้าว', tambonEn: 'Lat Phrao',
71
- // amphure: 'ลาดพร้าว', amphureEn: 'Lat Phrao',
72
- // province: 'กรุงเทพมหานคร', provinceEn: 'Bangkok',
73
- // zipCode: '10230',
74
- // }
75
- ```
49
+ ### Postal codes
76
50
 
77
- **`resolveThaiAddress`** — ใช้บันทึกข้อมูลหลังผู้ใช้เลือก
51
+ One Thai postal code can cover many subdistricts — `45000` covers 33, and 230 of the 953 codes cover more than 10 — so `zipLimit` defaults to unlimited rather than silently truncating at `limit`.
78
52
 
79
53
  ```ts
80
- import { resolveThaiAddress } from 'thaizip'
81
-
82
- const resolved = resolveThaiAddress(results[0])
83
- // {
84
- // tambon: 'ลาดพร้าว', tambonEn: 'Lat Phrao',
85
- // amphure: 'ลาดพร้าว', amphureEn: 'Lat Phrao',
86
- // province: 'กรุงเทพมหานคร', provinceEn: 'Bangkok',
87
- // zipCode: '10230',
88
- // subdistrict: 'ลาดพร้าว', subdistrictEn: 'Lat Phrao',
89
- // district: 'ลาดพร้าว', districtEn: 'Lat Phrao',
90
- // postalCode: '10230',
91
- // }
92
- ```
93
-
94
- ---
95
-
96
- ## การใช้งานกับ React
97
-
98
- ### `useThaiAddressAutocomplete`
54
+ import { lookupByZipCode } from 'thaizip'
99
55
 
100
- Hook นี้จัดการ state ของ query, suggestions, debounce ให้ทั้งหมด
56
+ lookupByZipCode(index, '45000') // exact
57
+ lookupByZipCode(index, '450') // prefix
58
+ ```
101
59
 
102
- ```tsx
103
- import { useState, useEffect } from 'react'
104
- import { loadDefaultIndex } from 'thaizip/data'
105
- import { useThaiAddressAutocomplete } from 'thaizip'
106
- import type { TrigramIndex } from 'thaizip'
60
+ ### English input
107
61
 
108
- // แนะนำ: แยก loading และ form ออกจากกัน เพื่อให้ hook เรียกหลัง index พร้อมแล้วเสมอ
109
- function AddressPage() {
110
- const [index, setIndex] = useState<TrigramIndex | null>(null)
62
+ The dataset indexes official RTGS spellings, so `bang rak` and `chatuchak` work directly. 84 common non-RTGS spellings are mapped on first: `lardprao` → `lat phrao`, `ladkrabang` → `lat krabang`, `krungthep` → `bangkok`. It is a curated dictionary, not a transliterator — unlisted spellings still miss. Opt out with `romanizationAliases: false`.
111
63
 
112
- useEffect(() => {
113
- loadDefaultIndex().then(setIndex)
114
- }, [])
64
+ ### Cascade dropdowns
115
65
 
116
- if (!index) return <p>กำลังโหลด...</p>
117
- return <AddressForm index={index} />
118
- }
66
+ ```ts
67
+ import { listProvinces, listAmphures, listTambons } from 'thaizip'
119
68
 
120
- function AddressForm({ index }: { index: TrigramIndex }) {
121
- const { query, setQuery, suggestions, isOpen, selectSuggestion, clear } =
122
- useThaiAddressAutocomplete({
123
- index,
124
- limit: 10, // default: 10
125
- debounce: 200, // default: 200ms
126
- threshold: 0.4, // default: 0.4
127
- })
128
-
129
- const [address, setAddress] = useState(null)
130
-
131
- return (
132
- <div>
133
- <input
134
- value={query}
135
- onChange={(e) => setQuery(e.target.value)}
136
- placeholder="พิมพ์ตำบล อำเภอ จังหวัด หรือรหัสไปรษณีย์"
137
- />
138
-
139
- {isOpen && (
140
- <ul>
141
- {suggestions.map((s) => (
142
- <li key={s.id} onClick={() => {
143
- const resolved = selectSuggestion(s)
144
- setAddress(resolved)
145
- }}>
146
- {s.label}
147
- </li>
148
- ))}
149
- </ul>
150
- )}
151
-
152
- {address && (
153
- <div>
154
- <p>ตำบล: {address.tambon} ({address.tambonEn})</p>
155
- <p>อำเภอ: {address.amphure} ({address.amphureEn})</p>
156
- <p>จังหวัด: {address.province} ({address.provinceEn})</p>
157
- <p>รหัสไปรษณีย์: {address.zipCode}</p>
158
- </div>
159
- )}
160
- </div>
161
- )
162
- }
69
+ listProvinces(index) // 77, sorted by Thai name
70
+ listAmphures(index, provinceId)
71
+ listTambons(index, amphureId) // each with its zipCode
163
72
  ```
164
73
 
165
- ### Return ของ hook
166
-
167
- | ค่า | ประเภท | คำอธิบาย |
168
- |---|---|---|
169
- | `query` | `string` | ข้อความที่ผู้ใช้พิมพ์อยู่ |
170
- | `setQuery` | `(value: string) => void` | อัปเดต query |
171
- | `suggestions` | `ThaiAddressSuggestion[]` | รายการผลลัพธ์ที่แสดงใน dropdown |
172
- | `isOpen` | `boolean` | `true` เมื่อมี query และมี suggestions |
173
- | `selectSuggestion` | `(item) => ResolvedThaiAddress` | เลือก suggestion แล้วได้ข้อมูลที่อยู่ครบ |
174
- | `clear` | `() => void` | ล้าง query และ suggestions ทั้งหมด |
74
+ Backed by pre-built groupings, not scans over all 7,385 records. Unknown ids return `[]`.
175
75
 
176
- ---
76
+ ## React
177
77
 
178
- ## การใช้งานกับ Node.js / Express
78
+ ```tsx
79
+ import { useThaiAddressAutocomplete } from 'thaizip/react'
179
80
 
180
- ```ts
181
- import express from 'express'
182
- import { loadDefaultIndex } from 'thaizip/data'
183
- import { searchThaiAddress, formatThaiAddressSuggestion } from 'thaizip'
81
+ const { query, setQuery, setQuerySilent, suggestions, isOpen, selectSuggestion, clear } =
82
+ useThaiAddressAutocomplete({ index })
83
+ ```
184
84
 
185
- const app = express()
85
+ Options: `index`, `limit`, `debounce` (200ms), `threshold`, `zipLimit`, `locale`, `initialQuery`, `onSelect`.
186
86
 
187
- // โหลด index ครั้งเดียวตอน startup
188
- const index = await loadDefaultIndex()
87
+ | value | description |
88
+ |---|---|
89
+ | `query` / `setQuery` | input value; `setQuery` searches |
90
+ | `setQuerySilent` | set the value **without** searching or reopening the dropdown |
91
+ | `suggestions` | `ThaiAddressSuggestion[]` |
92
+ | `isOpen` | `query` is non-empty and suggestions exist |
93
+ | `selectSuggestion` | `(item) => ResolvedThaiAddress \| null` — clears suggestions, leaves `query` alone; `null` if the item is stale |
94
+ | `clear` | reset query and suggestions |
189
95
 
190
- app.get('/address/search', (req, res) => {
191
- const query = String(req.query.q ?? '')
192
- if (!query) return res.json([])
96
+ ### Controlled forms
193
97
 
194
- const results = searchThaiAddress(index, query, { limit: 10 })
195
- res.json(results.map(formatThaiAddressSuggestion))
196
- })
98
+ `selectSuggestion` leaves `query` untouched by design. To echo the choice back into the input use `setQuerySilent` — plain `setQuery` would immediately reopen the dropdown:
197
99
 
198
- app.listen(3000)
100
+ ```tsx
101
+ const handlePick = (s: ThaiAddressSuggestion) => {
102
+ const address = selectSuggestion(s)
103
+ if (!address) return
104
+ setQuerySilent(s.label)
105
+ form.setValue('address', address)
106
+ }
199
107
  ```
200
108
 
201
- เรียกใช้งาน:
202
- ```
203
- GET /address/search?q=ลาดพร้าว
204
- GET /address/search?q=10900
205
- ```
109
+ Editing a saved address? Pass `initialQuery` rather than calling `setQuery` in an effect.
206
110
 
207
- ---
111
+ ### Accessibility
208
112
 
209
- ## ใช้ข้อมูล Index ของตัวเอง
113
+ The hook owns query and suggestion state only. Keyboard navigation and ARIA combobox semantics are yours — `npx react-thaizip add autocomplete` scaffolds a component that already has them.
210
114
 
211
- หากต้องการสร้าง index จากข้อมูลที่กำหนดเอง
115
+ ## Custom data
212
116
 
213
117
  ```ts
214
- import { buildThaiAddressIndex } from 'thaizip'
118
+ import { buildThaiAddressIndex, validateRawData } from 'thaizip'
215
119
 
216
- const index = buildThaiAddressIndex({
217
- provinces: [...],
218
- amphures: [...],
219
- tambons: [...],
220
- // geographies ไม่จำเป็นต้องระบุ (ไม่ได้ใช้งานโดย indexer)
120
+ const index = buildThaiAddressIndex({ provinces, amphures, tambons }, {
121
+ onSkip: (tambon) => console.warn('skipped:', tambon.id),
122
+ validate: true, // default
221
123
  })
222
124
  ```
223
125
 
224
- รูปแบบข้อมูล raw ดูได้จาก type `RawData`, `RawProvince`, `RawAmphure`, `RawTambon` ที่ export มาจาก library
126
+ Input is validated by default, so a stray non-string field fails with `[thaizip] tambon 100404: expected string for name_th, got number` instead of crashing inside the normalizer. It costs nothing measurable on a full-size dataset. `validateRawData(data)` runs the same checks standalone.
225
127
 
226
- ---
128
+ Types: `RawData`, `RawProvince`, `RawAmphure`, `RawTambon`.
227
129
 
228
- ## Types หลัก
130
+ ## Types
229
131
 
230
132
  ```ts
231
133
  type ThaiAddressSuggestion = {
232
134
  id: string
233
- label: string // "ตำบล > อำเภอ > จังหวัด XXXXX"
234
- tambon: string
235
- tambonEn: string
236
- amphure: string
237
- amphureEn: string
238
- province: string
239
- provinceEn: string
135
+ label: string // follows `locale`, Thai by default
136
+ labelTh: string
137
+ labelEn: string
138
+ tambon: string; tambonEn: string
139
+ amphure: string; amphureEn: string
140
+ province: string; provinceEn: string
240
141
  zipCode: string
241
142
  }
242
143
 
243
144
  type ResolvedThaiAddress = {
244
- tambon: string // alias: subdistrict
245
- tambonEn: string // alias: subdistrictEn
246
- amphure: string // alias: district
247
- amphureEn: string // alias: districtEn
248
- province: string
249
- provinceEn: string
250
- zipCode: string // alias: postalCode
251
- subdistrict: string
252
- subdistrictEn: string
253
- district: string
254
- districtEn: string
145
+ tambon: string; tambonEn: string // alias: subdistrict / subdistrictEn
146
+ amphure: string; amphureEn: string // alias: district / districtEn
147
+ province: string; provinceEn: string
148
+ zipCode: string // alias: postalCode
149
+ subdistrict: string; subdistrictEn: string
150
+ district: string; districtEn: string
255
151
  postalCode: string
256
152
  }
257
153
  ```
258
154
 
259
- ---
260
-
261
- ## Migration จาก v0.2.x → v0.3.0+
262
-
263
- ```ts
264
- // v0.2.x (sync, bundle ~630KB)
265
- import { defaultIndex } from 'thaizip/data'
266
- const results = searchThaiAddress(defaultIndex, query)
267
-
268
- // v0.3.0+ (async, bundle ~132KB gzip)
269
- import { loadDefaultIndex } from 'thaizip/data'
270
- const index = await loadDefaultIndex() // cache อัตโนมัติ หลังจากโหลดครั้งแรก
271
- const results = searchThaiAddress(index, query)
272
- ```
273
-
274
- ---
275
-
276
- ## ข้อมูลที่อยู่
277
-
278
- ข้อมูลอ้างอิงจากการแบ่งเขตการปกครองของไทย (77 จังหวัด, 920 อำเภอ, ~7,385 ตำบล)
155
+ ## Data
279
156
 
280
- ตำบลที่ยังมีสถานะ active แต่อ้างอิงอำเภอที่ถูก soft-deleted จะถูกข้ามโดย index โดยอัตโนมัติ พร้อมแสดง `console.warn` ให้ทราบ
157
+ 77 provinces, 918 districts, 7,385 subdistricts. Records whose parent district or province is soft-deleted are excluded automatically.
281
158
 
282
- ---
159
+ `searchThaiAddress` is pure and framework-free — it works unchanged in Node, Vue, Svelte, or vanilla JS.
283
160
 
284
161
  ## License
285
162
 
package/dist/data.cjs CHANGED
@@ -27,8 +27,8 @@ var init_defaultData = __esm({
27
27
  });
28
28
 
29
29
  // src/core/normalizer.ts
30
- var TONE_MARKS = /[\u0E48-\u0E4B\u0E47\u0E4C]/g;
31
- var ADDRESS_PREFIXES = /^(จังหวัด|อำเภอ|ตำบล|แขวง|เขต)/;
30
+ var TONE_MARKS = /[่-๋็์]/g;
31
+ var ADDRESS_PREFIXES = /^(จังหวัด|อำเภอ|ตำบล|แขวง|เขต|จ\.|อ\.|ต\.|ข\.)/;
32
32
  function normalizeThaiAddressText(input) {
33
33
  if (!input) return "";
34
34
  let text = input.trim();
@@ -56,7 +56,56 @@ function extractTrigramsNormalized(text) {
56
56
  }
57
57
 
58
58
  // src/core/indexer.ts
59
- function buildThaiAddressIndex(data) {
59
+ function typeErr(table, id, field, expected, value) {
60
+ return new TypeError(`[thaizip] ${table} ${id}: expected ${expected} for ${field}, got ${typeof value}`);
61
+ }
62
+ function validateRawData(data) {
63
+ const { provinces, amphures, tambons } = data;
64
+ for (let i = 0; i < provinces.length; i++) {
65
+ const p2 = provinces[i];
66
+ if (typeof p2.id !== "number") throw typeErr("province", p2.id, "id", "number", p2.id);
67
+ if (typeof p2.name_th !== "string") throw typeErr("province", p2.id, "name_th", "string", p2.name_th);
68
+ if (typeof p2.name_en !== "string") throw typeErr("province", p2.id, "name_en", "string", p2.name_en);
69
+ }
70
+ for (let i = 0; i < amphures.length; i++) {
71
+ const a2 = amphures[i];
72
+ if (typeof a2.id !== "number") throw typeErr("amphure", a2.id, "id", "number", a2.id);
73
+ if (typeof a2.name_th !== "string") throw typeErr("amphure", a2.id, "name_th", "string", a2.name_th);
74
+ if (typeof a2.name_en !== "string") throw typeErr("amphure", a2.id, "name_en", "string", a2.name_en);
75
+ if (typeof a2.province_id !== "number") throw typeErr("amphure", a2.id, "province_id", "number", a2.province_id);
76
+ }
77
+ for (let i = 0; i < tambons.length; i++) {
78
+ const t2 = tambons[i];
79
+ if (typeof t2.id !== "number") throw typeErr("tambon", t2.id, "id", "number", t2.id);
80
+ if (typeof t2.name_th !== "string") throw typeErr("tambon", t2.id, "name_th", "string", t2.name_th);
81
+ if (typeof t2.name_en !== "string") throw typeErr("tambon", t2.id, "name_en", "string", t2.name_en);
82
+ const zipType = typeof t2.zip_code;
83
+ if (zipType !== "string" && zipType !== "number") {
84
+ throw typeErr("tambon", t2.id, "zip_code", "string or number", t2.zip_code);
85
+ }
86
+ if (typeof t2.amphure_id !== "number") throw typeErr("tambon", t2.id, "amphure_id", "number", t2.amphure_id);
87
+ }
88
+ }
89
+ function addTrigrams(map, trigrams, idx) {
90
+ for (const trigram of trigrams) {
91
+ let set = map.get(trigram);
92
+ if (!set) {
93
+ set = /* @__PURE__ */ new Set();
94
+ map.set(trigram, set);
95
+ }
96
+ set.add(idx);
97
+ }
98
+ }
99
+ function combinedTrigrams(nameTh, nameEn) {
100
+ const s = /* @__PURE__ */ new Set();
101
+ for (const t2 of extractTrigrams(nameTh)) s.add(t2);
102
+ for (const t2 of extractTrigrams(nameEn)) s.add(t2);
103
+ return s;
104
+ }
105
+ function buildThaiAddressIndex(data, options) {
106
+ if (options?.validate !== false) {
107
+ validateRawData(data);
108
+ }
60
109
  const { provinces, amphures, tambons } = data;
61
110
  const provMap = new Map(
62
111
  provinces.filter((p2) => !p2.deleted_at).map((p2) => [p2.id, p2])
@@ -64,20 +113,33 @@ function buildThaiAddressIndex(data) {
64
113
  const ampMap = new Map(
65
114
  amphures.filter((a2) => !a2.deleted_at).map((a2) => [a2.id, a2])
66
115
  );
116
+ const provTrigrams = /* @__PURE__ */ new Map();
117
+ for (const [id, prov] of provMap) {
118
+ provTrigrams.set(id, combinedTrigrams(prov.name_th, prov.name_en));
119
+ }
120
+ const ampTrigrams = /* @__PURE__ */ new Map();
121
+ for (const [id, amp] of ampMap) {
122
+ ampTrigrams.set(id, combinedTrigrams(amp.name_th, amp.name_en));
123
+ }
67
124
  const records = [];
68
125
  const map = /* @__PURE__ */ new Map();
69
126
  const zipIndex = /* @__PURE__ */ new Map();
127
+ const normTambon = [];
128
+ const normTambonEn = [];
129
+ const byProvince = /* @__PURE__ */ new Map();
130
+ const byAmphure = /* @__PURE__ */ new Map();
70
131
  for (const tambon of tambons) {
71
132
  if (tambon.deleted_at) continue;
72
133
  const amphure = ampMap.get(tambon.amphure_id);
73
134
  if (!amphure) {
74
- console.warn(
75
- `[thaizip] skipping tambon id=${tambon.id} (${tambon.name_th}): amphure_id=${tambon.amphure_id} not found or deleted`
76
- );
135
+ if (options?.onSkip) options.onSkip(tambon);
77
136
  continue;
78
137
  }
79
138
  const province = provMap.get(amphure.province_id);
80
- if (!province) continue;
139
+ if (!province) {
140
+ if (options?.onSkip) options.onSkip(tambon);
141
+ continue;
142
+ }
81
143
  const record = {
82
144
  provinceId: province.id,
83
145
  provinceNameTh: province.name_th,
@@ -95,43 +157,55 @@ function buildThaiAddressIndex(data) {
95
157
  const existing = zipIndex.get(record.zipCode);
96
158
  if (existing) existing.push(idx);
97
159
  else zipIndex.set(record.zipCode, [idx]);
98
- const fields = [
99
- record.tambonNameTh,
100
- record.tambonNameEn,
101
- record.amphureNameTh,
102
- record.amphureNameEn,
103
- record.provinceNameTh,
104
- record.provinceNameEn,
105
- record.zipCode
106
- ];
107
- for (const field of fields) {
108
- for (const trigram of extractTrigrams(field)) {
109
- let set = map.get(trigram);
110
- if (!set) {
111
- set = /* @__PURE__ */ new Set();
112
- map.set(trigram, set);
113
- }
114
- set.add(idx);
115
- }
116
- }
160
+ const provList = byProvince.get(province.id);
161
+ if (provList) provList.push(idx);
162
+ else byProvince.set(province.id, [idx]);
163
+ const ampList = byAmphure.get(amphure.id);
164
+ if (ampList) ampList.push(idx);
165
+ else byAmphure.set(amphure.id, [idx]);
166
+ const normTh = normalizeThaiAddressText(record.tambonNameTh);
167
+ normTambon.push(normTh);
168
+ addTrigrams(map, extractTrigramsNormalized(normTh), idx);
169
+ const normEn = normalizeThaiAddressText(record.tambonNameEn);
170
+ normTambonEn.push(normEn);
171
+ addTrigrams(map, extractTrigramsNormalized(normEn), idx);
172
+ addTrigrams(map, extractTrigrams(record.zipCode), idx);
173
+ addTrigrams(map, provTrigrams.get(province.id), idx);
174
+ addTrigrams(map, ampTrigrams.get(amphure.id), idx);
117
175
  }
118
- return { map, records, zipIndex };
176
+ return { map, records, zipIndex, normTambon, normTambonEn, byProvince, byAmphure };
119
177
  }
120
178
 
121
179
  // src/data/loader.ts
122
180
  var cached = null;
181
+ var inflightPromise = null;
182
+ var loadGeneration = 0;
123
183
  function clearDefaultIndex() {
124
184
  cached = null;
185
+ inflightPromise = null;
186
+ loadGeneration++;
125
187
  }
126
188
  async function loadDefaultIndex() {
127
189
  if (cached) return cached;
128
- const { p: p2, a: a2, t: t2 } = await Promise.resolve().then(() => (init_defaultData(), defaultData_exports));
129
- cached = buildThaiAddressIndex({
130
- provinces: p2.map(([id, name_th, name_en]) => ({ id, name_th, name_en, geography_id: 0, deleted_at: null })),
131
- amphures: a2.map(([id, name_th, name_en, province_id]) => ({ id, name_th, name_en, province_id, deleted_at: null })),
132
- tambons: t2.map(([id, name_th, name_en, amphure_id, zip_code]) => ({ id, name_th, name_en, amphure_id, zip_code: Number(zip_code), deleted_at: null }))
133
- });
134
- return cached;
190
+ if (!inflightPromise) {
191
+ const gen = loadGeneration;
192
+ inflightPromise = Promise.resolve().then(() => (init_defaultData(), defaultData_exports)).then(({ p: p2, a: a2, t: t2 }) => {
193
+ const index = buildThaiAddressIndex({
194
+ provinces: p2.map(([id, name_th, name_en]) => ({ id, name_th, name_en, geography_id: 0, deleted_at: null })),
195
+ amphures: a2.map(([id, name_th, name_en, province_id]) => ({ id, name_th, name_en, province_id, deleted_at: null })),
196
+ tambons: t2.map(([id, name_th, name_en, amphure_id, zip_code]) => ({ id, name_th, name_en, amphure_id, zip_code, deleted_at: null }))
197
+ }, { validate: false });
198
+ if (loadGeneration === gen) {
199
+ cached = index;
200
+ inflightPromise = null;
201
+ }
202
+ return index;
203
+ }).catch((err) => {
204
+ if (loadGeneration === gen) inflightPromise = null;
205
+ throw err;
206
+ });
207
+ }
208
+ return inflightPromise;
135
209
  }
136
210
 
137
211
  exports.clearDefaultIndex = clearDefaultIndex;
package/dist/data.d.cts CHANGED
@@ -14,6 +14,22 @@ type TrigramIndex = {
14
14
  map: Map<string, Set<number>>;
15
15
  records: ThaiAddressRecord[];
16
16
  zipIndex: Map<string, number[]>;
17
+ /**
18
+ * Normalized Thai tambon names, parallel to `records` (same index).
19
+ * Used by the search ranker for exact/prefix match detection without
20
+ * re-normalizing on every query.
21
+ */
22
+ normTambon: string[];
23
+ /**
24
+ * Normalized English (RTGS) tambon names, parallel to `records`. Needed so
25
+ * romanized queries get the same own-name-beats-parent-name ranking that
26
+ * Thai queries get.
27
+ */
28
+ normTambonEn: string[];
29
+ /** Record indices grouped by `provinceId`, in insertion order. */
30
+ byProvince: Map<number, number[]>;
31
+ /** Record indices grouped by `amphureId`, in insertion order. */
32
+ byAmphure: Map<number, number[]>;
17
33
  };
18
34
 
19
35
  declare function clearDefaultIndex(): void;
package/dist/data.d.ts CHANGED
@@ -14,6 +14,22 @@ type TrigramIndex = {
14
14
  map: Map<string, Set<number>>;
15
15
  records: ThaiAddressRecord[];
16
16
  zipIndex: Map<string, number[]>;
17
+ /**
18
+ * Normalized Thai tambon names, parallel to `records` (same index).
19
+ * Used by the search ranker for exact/prefix match detection without
20
+ * re-normalizing on every query.
21
+ */
22
+ normTambon: string[];
23
+ /**
24
+ * Normalized English (RTGS) tambon names, parallel to `records`. Needed so
25
+ * romanized queries get the same own-name-beats-parent-name ranking that
26
+ * Thai queries get.
27
+ */
28
+ normTambonEn: string[];
29
+ /** Record indices grouped by `provinceId`, in insertion order. */
30
+ byProvince: Map<number, number[]>;
31
+ /** Record indices grouped by `amphureId`, in insertion order. */
32
+ byAmphure: Map<number, number[]>;
17
33
  };
18
34
 
19
35
  declare function clearDefaultIndex(): void;