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 +90 -213
- package/dist/data.cjs +108 -34
- package/dist/data.d.cts +16 -0
- package/dist/data.d.ts +16 -0
- package/dist/data.js +108 -34
- package/dist/index.cjs +327 -105
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +160 -20
- package/dist/index.d.ts +160 -20
- package/dist/index.js +322 -104
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +374 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +111 -0
- package/dist/react.d.ts +111 -0
- package/dist/react.js +372 -0
- package/dist/react.js.map +1 -0
- package/package.json +9 -4
package/README.md
CHANGED
|
@@ -1,285 +1,162 @@
|
|
|
1
1
|
# thaizip
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
const index = await loadDefaultIndex()
|
|
21
|
+
const index = await loadDefaultIndex() // ~40ms of synchronous work, cached after
|
|
33
22
|
|
|
34
|
-
|
|
35
|
-
|
|
23
|
+
searchThaiAddress(index, 'ลาดพร้าว')
|
|
24
|
+
searchThaiAddress(index, 'bang rak')
|
|
25
|
+
searchThaiAddress(index, '10500')
|
|
36
26
|
|
|
37
|
-
//
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
limit:
|
|
55
|
-
threshold: 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
|
-
|
|
47
|
+
> Combined text + zip in one query (`"ลาดพร้าว 10900"`) is not supported — search by name or zip separately.
|
|
62
48
|
|
|
63
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
56
|
+
lookupByZipCode(index, '45000') // exact
|
|
57
|
+
lookupByZipCode(index, '450') // prefix
|
|
58
|
+
```
|
|
101
59
|
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
loadDefaultIndex().then(setIndex)
|
|
114
|
-
}, [])
|
|
64
|
+
### Cascade dropdowns
|
|
115
65
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
66
|
+
```ts
|
|
67
|
+
import { listProvinces, listAmphures, listTambons } from 'thaizip'
|
|
119
68
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
+
```tsx
|
|
79
|
+
import { useThaiAddressAutocomplete } from 'thaizip/react'
|
|
179
80
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
import { searchThaiAddress, formatThaiAddressSuggestion } from 'thaizip'
|
|
81
|
+
const { query, setQuery, setQuerySilent, suggestions, isOpen, selectSuggestion, clear } =
|
|
82
|
+
useThaiAddressAutocomplete({ index })
|
|
83
|
+
```
|
|
184
84
|
|
|
185
|
-
|
|
85
|
+
Options: `index`, `limit`, `debounce` (200ms), `threshold`, `zipLimit`, `locale`, `initialQuery`, `onSelect`.
|
|
186
86
|
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
const query = String(req.query.q ?? '')
|
|
192
|
-
if (!query) return res.json([])
|
|
96
|
+
### Controlled forms
|
|
193
97
|
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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 = /[
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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;
|