thaizip 0.6.0 → 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,337 +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
- ## Package exports
24
-
25
- | import path | เนื้อหา |
9
+ | import path | contents |
26
10
  |---|---|
27
- | `thaizip` | core functions + types ทั้งหมด (ไม่มี React) |
28
- | `thaizip/react` | `useThaiAddressAutocomplete` hook + types |
29
- | `thaizip/data` | `loadDefaultIndex`, `clearDefaultIndex` |
30
-
31
- ---
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) |
32
14
 
33
- ## การใช้งานพื้นฐาน (Vanilla JS / TypeScript)
34
-
35
- ### โหลด index และค้นหาที่อยู่
15
+ ## Quick start
36
16
 
37
17
  ```ts
38
18
  import { loadDefaultIndex } from 'thaizip/data'
39
- import { searchThaiAddress } from 'thaizip'
40
-
41
- // โหลด index ครั้งแรก ~200ms (cached หลังจากนั้น)
42
- const index = await loadDefaultIndex()
19
+ import { searchThaiAddress, formatThaiAddressSuggestion, resolveThaiAddress } from 'thaizip'
43
20
 
44
- // ค้นหาด้วยชื่อตำบล/อำเภอ/จังหวัด
45
- const results = searchThaiAddress(index, 'ลาดพร้าว')
21
+ const index = await loadDefaultIndex() // ~40ms of synchronous work, cached after
46
22
 
47
- // ค้นหาด้วยรหัสไปรษณีย์
48
- const results2 = searchThaiAddress(index, '10900')
23
+ searchThaiAddress(index, 'ลาดพร้าว')
24
+ searchThaiAddress(index, 'bang rak')
25
+ searchThaiAddress(index, '10500')
49
26
 
50
- // ค้นหาด้วยภาษาอังกฤษ
51
- const results3 = searchThaiAddress(index, 'chiang mai')
52
-
53
- // ⚠️ ไม่รองรับ: ค้นหาแบบรวม text + รหัสไปรษณีย์ในครั้งเดียว (เช่น "ลาดพร้าว 10900")
54
- // ให้ค้นหาด้วยชื่อสถานที่ หรือรหัสไปรษณีย์ แยกกัน
55
-
56
- console.log(results)
57
- // [{ tambonId, tambonNameTh, tambonNameEn, amphureId, amphureNameTh, ... zipCode }, ...]
27
+ formatThaiAddressSuggestion(record) // for the dropdown
28
+ formatThaiAddressSuggestion(record, { locale: 'en' })
29
+ resolveThaiAddress(record) // for saving
58
30
  ```
59
31
 
60
- ### ตัวเลือก (options)
32
+ Call `loadDefaultIndex()` at mount or route load — deferring it to the user's first keystroke costs ~2 dropped frames.
33
+
34
+ ## Search
61
35
 
62
36
  ```ts
63
- const results = searchThaiAddress(index, 'ลาดพร้าว', {
64
- limit: 5, // จำนวนผลลัพธ์สูงสุด (default: 10)
65
- 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
66
42
  })
67
43
  ```
68
44
 
69
- ### แปลงผลลัพธ์เป็นรูปแบบต่าง ๆ
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 เขตลาดพร้าว.
70
46
 
71
- **`formatThaiAddressSuggestion`** — ใช้แสดงใน dropdown
47
+ > Combined text + zip in one query (`"ลาดพร้าว 10900"`) is not supported — search by name or zip separately.
72
48
 
73
- ```ts
74
- import { formatThaiAddressSuggestion } from 'thaizip'
75
-
76
- const suggestion = formatThaiAddressSuggestion(results[0])
77
- // {
78
- // id: '100101',
79
- // label: 'ลาดพร้าว > ลาดพร้าว > กรุงเทพมหานคร 10230',
80
- // tambon: 'ลาดพร้าว', tambonEn: 'Lat Phrao',
81
- // amphure: 'ลาดพร้าว', amphureEn: 'Lat Phrao',
82
- // province: 'กรุงเทพมหานคร', provinceEn: 'Bangkok',
83
- // zipCode: '10230',
84
- // }
85
- ```
49
+ ### Postal codes
86
50
 
87
- **`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`.
88
52
 
89
53
  ```ts
90
- import { resolveThaiAddress } from 'thaizip'
91
-
92
- const resolved = resolveThaiAddress(results[0])
93
- // {
94
- // tambon: 'ลาดพร้าว', tambonEn: 'Lat Phrao',
95
- // amphure: 'ลาดพร้าว', amphureEn: 'Lat Phrao',
96
- // province: 'กรุงเทพมหานคร', provinceEn: 'Bangkok',
97
- // zipCode: '10230',
98
- // subdistrict: 'ลาดพร้าว', subdistrictEn: 'Lat Phrao',
99
- // district: 'ลาดพร้าว', districtEn: 'Lat Phrao',
100
- // postalCode: '10230',
101
- // }
102
- ```
103
-
104
- ---
105
-
106
- ## การใช้งานกับ React
54
+ import { lookupByZipCode } from 'thaizip'
107
55
 
108
- ### `useThaiAddressAutocomplete`
109
-
110
- Hook นี้จัดการ state ของ query, suggestions, debounce ให้ทั้งหมด **import จาก `thaizip/react`**
56
+ lookupByZipCode(index, '45000') // exact
57
+ lookupByZipCode(index, '450') // prefix
58
+ ```
111
59
 
112
- ```tsx
113
- import { useState, useEffect } from 'react'
114
- import { loadDefaultIndex } from 'thaizip/data'
115
- import { useThaiAddressAutocomplete } from 'thaizip/react'
116
- import type { TrigramIndex, ResolvedThaiAddress } from 'thaizip/react'
60
+ ### English input
117
61
 
118
- // แนะนำ: แยก loading และ form ออกจากกัน เพื่อให้ hook เรียกหลัง index พร้อมแล้วเสมอ
119
- function AddressPage() {
120
- 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`.
121
63
 
122
- useEffect(() => {
123
- loadDefaultIndex().then(setIndex)
124
- }, [])
64
+ ### Cascade dropdowns
125
65
 
126
- if (!index) return <p>กำลังโหลด...</p>
127
- return <AddressForm index={index} />
128
- }
66
+ ```ts
67
+ import { listProvinces, listAmphures, listTambons } from 'thaizip'
129
68
 
130
- function AddressForm({ index }: { index: TrigramIndex }) {
131
- const { query, setQuery, suggestions, isOpen, selectSuggestion, clear } =
132
- useThaiAddressAutocomplete({
133
- index,
134
- limit: 10, // default: 10
135
- debounce: 200, // default: 200ms
136
- threshold: 0.4, // default: 0.4
137
- })
138
-
139
- const [address, setAddress] = useState<ResolvedThaiAddress | null>(null)
140
-
141
- return (
142
- <div>
143
- <input
144
- value={query}
145
- onChange={(e) => setQuery(e.target.value)}
146
- placeholder="พิมพ์ตำบล อำเภอ จังหวัด หรือรหัสไปรษณีย์"
147
- />
148
-
149
- {isOpen && (
150
- <ul>
151
- {suggestions.map((s) => (
152
- <li key={s.id} onClick={() => {
153
- const resolved = selectSuggestion(s)
154
- setAddress(resolved)
155
- }}>
156
- {s.label}
157
- </li>
158
- ))}
159
- </ul>
160
- )}
161
-
162
- {address && (
163
- <div>
164
- <p>ตำบล: {address.tambon} ({address.tambonEn})</p>
165
- <p>อำเภอ: {address.amphure} ({address.amphureEn})</p>
166
- <p>จังหวัด: {address.province} ({address.provinceEn})</p>
167
- <p>รหัสไปรษณีย์: {address.zipCode}</p>
168
- </div>
169
- )}
170
- </div>
171
- )
172
- }
69
+ listProvinces(index) // 77, sorted by Thai name
70
+ listAmphures(index, provinceId)
71
+ listTambons(index, amphureId) // each with its zipCode
173
72
  ```
174
73
 
175
- ### Return ของ hook
74
+ Backed by pre-built groupings, not scans over all 7,385 records. Unknown ids return `[]`.
176
75
 
177
- | ค่า | ประเภท | คำอธิบาย |
178
- |---|---|---|
179
- | `query` | `string` | ข้อความที่ผู้ใช้พิมพ์อยู่ |
180
- | `setQuery` | `(value: string) => void` | อัปเดต query |
181
- | `suggestions` | `ThaiAddressSuggestion[]` | รายการผลลัพธ์ที่แสดงใน dropdown |
182
- | `isOpen` | `boolean` | `true` เมื่อมี query และมี suggestions |
183
- | `selectSuggestion` | `(item) => ResolvedThaiAddress` | เลือก suggestion แล้วได้ข้อมูลที่อยู่ครบ |
184
- | `clear` | `() => void` | ล้าง query และ suggestions ทั้งหมด |
76
+ ## React
185
77
 
186
- ---
187
-
188
- ## การใช้งานกับ Node.js / Express
189
-
190
- ```ts
191
- import express from 'express'
192
- import { loadDefaultIndex } from 'thaizip/data'
193
- import { searchThaiAddress, formatThaiAddressSuggestion } from 'thaizip'
78
+ ```tsx
79
+ import { useThaiAddressAutocomplete } from 'thaizip/react'
194
80
 
195
- const app = express()
81
+ const { query, setQuery, setQuerySilent, suggestions, isOpen, selectSuggestion, clear } =
82
+ useThaiAddressAutocomplete({ index })
83
+ ```
196
84
 
197
- // โหลด index ครั้งเดียวตอน startup
198
- const index = await loadDefaultIndex()
85
+ Options: `index`, `limit`, `debounce` (200ms), `threshold`, `zipLimit`, `locale`, `initialQuery`, `onSelect`.
199
86
 
200
- app.get('/address/search', (req, res) => {
201
- const query = String(req.query.q ?? '')
202
- if (!query) return res.json([])
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 |
203
95
 
204
- const results = searchThaiAddress(index, query, { limit: 10 })
205
- res.json(results.map(formatThaiAddressSuggestion))
206
- })
96
+ ### Controlled forms
207
97
 
208
- app.listen(3000)
209
- ```
98
+ `selectSuggestion` leaves `query` untouched by design. To echo the choice back into the input use `setQuerySilent` — plain `setQuery` would immediately reopen the dropdown:
210
99
 
211
- เรียกใช้งาน:
212
- ```
213
- GET /address/search?q=ลาดพร้าว
214
- GET /address/search?q=10900
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
+ }
215
107
  ```
216
108
 
217
- ---
109
+ Editing a saved address? Pass `initialQuery` rather than calling `setQuery` in an effect.
218
110
 
219
- ## ใช้ข้อมูล Index ของตัวเอง
111
+ ### Accessibility
220
112
 
221
- หากต้องการสร้าง index จากข้อมูลที่กำหนดเอง
222
-
223
- ```ts
224
- import { buildThaiAddressIndex } from 'thaizip'
225
-
226
- const index = buildThaiAddressIndex({
227
- provinces: [...],
228
- amphures: [...],
229
- tambons: [...],
230
- // geographies ไม่จำเป็นต้องระบุ (ไม่ได้ใช้งานโดย indexer)
231
- }, {
232
- // รับ callback เมื่อ tambon ถูกข้ามเนื่องจากไม่พบ amphure/province
233
- onSkip: (tambon) => console.warn('skipped tambon:', tambon.id),
234
- })
235
- ```
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.
236
114
 
237
- ### reset cache (สำหรับ test isolation)
115
+ ## Custom data
238
116
 
239
117
  ```ts
240
- import { clearDefaultIndex } from 'thaizip/data'
118
+ import { buildThaiAddressIndex, validateRawData } from 'thaizip'
241
119
 
242
- clearDefaultIndex() // รีเซ็ต singleton cache ของ loadDefaultIndex
120
+ const index = buildThaiAddressIndex({ provinces, amphures, tambons }, {
121
+ onSkip: (tambon) => console.warn('skipped:', tambon.id),
122
+ validate: true, // default
123
+ })
243
124
  ```
244
125
 
245
- รูปแบบข้อมูล raw ดูได้จาก type `RawData`, `RawProvince`, `RawAmphure`, `RawTambon` ที่ export มาจาก `thaizip`
246
-
247
- ---
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.
248
127
 
249
- ## Types หลัก
128
+ Types: `RawData`, `RawProvince`, `RawAmphure`, `RawTambon`.
250
129
 
251
- Types ทั้งหมดสามารถ import ได้จาก `thaizip` หรือ `thaizip/react` (React-specific types)
130
+ ## Types
252
131
 
253
132
  ```ts
254
- // จาก thaizip หรือ thaizip/react
255
133
  type ThaiAddressSuggestion = {
256
134
  id: string
257
- label: string // "ตำบล > อำเภอ > จังหวัด XXXXX"
258
- tambon: string
259
- tambonEn: string
260
- amphure: string
261
- amphureEn: string
262
- province: string
263
- 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
264
141
  zipCode: string
265
142
  }
266
143
 
267
- // จาก thaizip หรือ thaizip/react
268
144
  type ResolvedThaiAddress = {
269
- tambon: string // alias: subdistrict
270
- tambonEn: string // alias: subdistrictEn
271
- amphure: string // alias: district
272
- amphureEn: string // alias: districtEn
273
- province: string
274
- provinceEn: string
275
- zipCode: string // alias: postalCode
276
- subdistrict: string
277
- subdistrictEn: string
278
- district: string
279
- 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
280
151
  postalCode: string
281
152
  }
282
153
  ```
283
154
 
284
- ---
285
-
286
- ## Migration จาก v0.5.x → v1.0.0
287
-
288
- ### hook ย้ายไปอยู่ที่ `thaizip/react`
289
-
290
- ```ts
291
- // v0.5.x
292
- import { useThaiAddressAutocomplete } from 'thaizip'
293
- import type { TrigramIndex } from 'thaizip'
294
-
295
- // v1.0.0
296
- import { useThaiAddressAutocomplete } from 'thaizip/react'
297
- import type { TrigramIndex, ResolvedThaiAddress, ThaiAddressSuggestion } from 'thaizip/react'
298
- // หรือยังคง import types จาก 'thaizip' ได้เช่นเดิม
299
- ```
300
-
301
- ### `clearDefaultIndex` ย้ายไปอยู่ที่ `thaizip/data`
302
-
303
- ```ts
304
- // v0.5.x
305
- import { clearDefaultIndex } from 'thaizip'
306
-
307
- // v1.0.0
308
- import { clearDefaultIndex } from 'thaizip/data'
309
- ```
310
-
311
- ---
312
-
313
- ## Migration จาก v0.2.x → v0.3.0+
314
-
315
- ```ts
316
- // v0.2.x (sync, bundle ~630KB)
317
- import { defaultIndex } from 'thaizip/data'
318
- const results = searchThaiAddress(defaultIndex, query)
319
-
320
- // v0.3.0+ (async, bundle ~132KB gzip)
321
- import { loadDefaultIndex } from 'thaizip/data'
322
- const index = await loadDefaultIndex() // cache อัตโนมัติ หลังจากโหลดครั้งแรก
323
- const results = searchThaiAddress(index, query)
324
- ```
325
-
326
- ---
327
-
328
- ## ข้อมูลที่อยู่
329
-
330
- ข้อมูลอ้างอิงจากการแบ่งเขตการปกครองของไทย (77 จังหวัด, 920 อำเภอ, ~7,385 ตำบล)
155
+ ## Data
331
156
 
332
- ตำบลที่ยังมีสถานะ active แต่อ้างอิงอำเภอหรือจังหวัดที่ถูก soft-deleted จะถูกข้ามโดย index โดยอัตโนมัติ ใช้ `onSkip` callback ใน `buildThaiAddressIndex` เพื่อรับแจ้ง
157
+ 77 provinces, 918 districts, 7,385 subdistricts. Records whose parent district or province is soft-deleted are excluded automatically.
333
158
 
334
- ---
159
+ `searchThaiAddress` is pure and framework-free — it works unchanged in Node, Vue, Svelte, or vanilla JS.
335
160
 
336
161
  ## License
337
162
 
package/dist/data.cjs CHANGED
@@ -56,6 +56,36 @@ function extractTrigramsNormalized(text) {
56
56
  }
57
57
 
58
58
  // src/core/indexer.ts
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
+ }
59
89
  function addTrigrams(map, trigrams, idx) {
60
90
  for (const trigram of trigrams) {
61
91
  let set = map.get(trigram);
@@ -73,6 +103,9 @@ function combinedTrigrams(nameTh, nameEn) {
73
103
  return s;
74
104
  }
75
105
  function buildThaiAddressIndex(data, options) {
106
+ if (options?.validate !== false) {
107
+ validateRawData(data);
108
+ }
76
109
  const { provinces, amphures, tambons } = data;
77
110
  const provMap = new Map(
78
111
  provinces.filter((p2) => !p2.deleted_at).map((p2) => [p2.id, p2])
@@ -91,14 +124,20 @@ function buildThaiAddressIndex(data, options) {
91
124
  const records = [];
92
125
  const map = /* @__PURE__ */ new Map();
93
126
  const zipIndex = /* @__PURE__ */ new Map();
127
+ const normTambon = [];
128
+ const normTambonEn = [];
129
+ const byProvince = /* @__PURE__ */ new Map();
130
+ const byAmphure = /* @__PURE__ */ new Map();
94
131
  for (const tambon of tambons) {
95
132
  if (tambon.deleted_at) continue;
96
133
  const amphure = ampMap.get(tambon.amphure_id);
97
134
  if (!amphure) {
135
+ if (options?.onSkip) options.onSkip(tambon);
98
136
  continue;
99
137
  }
100
138
  const province = provMap.get(amphure.province_id);
101
139
  if (!province) {
140
+ if (options?.onSkip) options.onSkip(tambon);
102
141
  continue;
103
142
  }
104
143
  const record = {
@@ -118,13 +157,23 @@ function buildThaiAddressIndex(data, options) {
118
157
  const existing = zipIndex.get(record.zipCode);
119
158
  if (existing) existing.push(idx);
120
159
  else zipIndex.set(record.zipCode, [idx]);
121
- addTrigrams(map, extractTrigrams(record.tambonNameTh), idx);
122
- addTrigrams(map, extractTrigrams(record.tambonNameEn), idx);
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);
123
172
  addTrigrams(map, extractTrigrams(record.zipCode), idx);
124
173
  addTrigrams(map, provTrigrams.get(province.id), idx);
125
174
  addTrigrams(map, ampTrigrams.get(amphure.id), idx);
126
175
  }
127
- return { map, records, zipIndex };
176
+ return { map, records, zipIndex, normTambon, normTambonEn, byProvince, byAmphure };
128
177
  }
129
178
 
130
179
  // src/data/loader.ts
@@ -145,7 +194,7 @@ async function loadDefaultIndex() {
145
194
  provinces: p2.map(([id, name_th, name_en]) => ({ id, name_th, name_en, geography_id: 0, deleted_at: null })),
146
195
  amphures: a2.map(([id, name_th, name_en, province_id]) => ({ id, name_th, name_en, province_id, deleted_at: null })),
147
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 }))
148
- });
197
+ }, { validate: false });
149
198
  if (loadGeneration === gen) {
150
199
  cached = index;
151
200
  inflightPromise = null;
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;