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/dist/react.d.cts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
|
|
3
|
+
type ThaiAddressRecord = {
|
|
4
|
+
provinceId: number;
|
|
5
|
+
provinceNameTh: string;
|
|
6
|
+
provinceNameEn: string;
|
|
7
|
+
amphureId: number;
|
|
8
|
+
amphureNameTh: string;
|
|
9
|
+
amphureNameEn: string;
|
|
10
|
+
tambonId: number;
|
|
11
|
+
tambonNameTh: string;
|
|
12
|
+
tambonNameEn: string;
|
|
13
|
+
zipCode: string;
|
|
14
|
+
};
|
|
15
|
+
type TrigramIndex = {
|
|
16
|
+
map: Map<string, Set<number>>;
|
|
17
|
+
records: ThaiAddressRecord[];
|
|
18
|
+
zipIndex: Map<string, number[]>;
|
|
19
|
+
/**
|
|
20
|
+
* Normalized Thai tambon names, parallel to `records` (same index).
|
|
21
|
+
* Used by the search ranker for exact/prefix match detection without
|
|
22
|
+
* re-normalizing on every query.
|
|
23
|
+
*/
|
|
24
|
+
normTambon: string[];
|
|
25
|
+
/**
|
|
26
|
+
* Normalized English (RTGS) tambon names, parallel to `records`. Needed so
|
|
27
|
+
* romanized queries get the same own-name-beats-parent-name ranking that
|
|
28
|
+
* Thai queries get.
|
|
29
|
+
*/
|
|
30
|
+
normTambonEn: string[];
|
|
31
|
+
/** Record indices grouped by `provinceId`, in insertion order. */
|
|
32
|
+
byProvince: Map<number, number[]>;
|
|
33
|
+
/** Record indices grouped by `amphureId`, in insertion order. */
|
|
34
|
+
byAmphure: Map<number, number[]>;
|
|
35
|
+
};
|
|
36
|
+
/** Which language a formatted label should be rendered in. */
|
|
37
|
+
type AddressLocale = 'th' | 'en';
|
|
38
|
+
type ThaiAddressSuggestion = {
|
|
39
|
+
id: string;
|
|
40
|
+
/** Human-readable label in the requested locale (Thai by default). */
|
|
41
|
+
label: string;
|
|
42
|
+
/** Human-readable label in Thai, regardless of the requested locale. */
|
|
43
|
+
labelTh: string;
|
|
44
|
+
/** Human-readable label in English, regardless of the requested locale. */
|
|
45
|
+
labelEn: string;
|
|
46
|
+
tambon: string;
|
|
47
|
+
tambonEn: string;
|
|
48
|
+
amphure: string;
|
|
49
|
+
amphureEn: string;
|
|
50
|
+
province: string;
|
|
51
|
+
provinceEn: string;
|
|
52
|
+
zipCode: string;
|
|
53
|
+
};
|
|
54
|
+
type ResolvedThaiAddress = {
|
|
55
|
+
tambon: string;
|
|
56
|
+
tambonEn: string;
|
|
57
|
+
amphure: string;
|
|
58
|
+
amphureEn: string;
|
|
59
|
+
province: string;
|
|
60
|
+
provinceEn: string;
|
|
61
|
+
zipCode: string;
|
|
62
|
+
subdistrict: string;
|
|
63
|
+
subdistrictEn: string;
|
|
64
|
+
district: string;
|
|
65
|
+
districtEn: string;
|
|
66
|
+
postalCode: string;
|
|
67
|
+
};
|
|
68
|
+
type UseThaiAddressAutocompleteOptions = {
|
|
69
|
+
index: TrigramIndex;
|
|
70
|
+
limit?: number;
|
|
71
|
+
debounce?: number;
|
|
72
|
+
threshold?: number;
|
|
73
|
+
zipLimit?: number;
|
|
74
|
+
/** Seed value for `query` on first render. Defaults to `''`. */
|
|
75
|
+
initialQuery?: string;
|
|
76
|
+
/** Locale for the `label` field of returned suggestions. Defaults to `'th'`. */
|
|
77
|
+
locale?: AddressLocale;
|
|
78
|
+
/** Called after a suggestion is successfully resolved via `selectSuggestion`. */
|
|
79
|
+
onSelect?: (address: ResolvedThaiAddress) => void;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Headless Thai address autocomplete hook.
|
|
84
|
+
*
|
|
85
|
+
* @param options.index - The TrigramIndex to search against. The hook reads `index`
|
|
86
|
+
* via an internal ref, so a new object reference does NOT restart the debounce
|
|
87
|
+
* timer. When `index` itself changes (e.g. swapped for a custom dataset), any
|
|
88
|
+
* pending query is re-searched immediately with the new index.
|
|
89
|
+
* @param options.limit - Maximum number of suggestions (default 10).
|
|
90
|
+
* @param options.debounce - Debounce delay in ms (default 200).
|
|
91
|
+
* @param options.threshold - Minimum trigram score 0–1 (default 0.4).
|
|
92
|
+
* @param options.zipLimit - Maximum results for all-digit (zip code) queries, passed
|
|
93
|
+
* through to `searchThaiAddress`.
|
|
94
|
+
* @param options.initialQuery - Seed value for `query` on first render. Does NOT
|
|
95
|
+
* trigger a search — useful for pre-filling the input with an already-chosen
|
|
96
|
+
* address (e.g. react-hook-form `defaultValues`) without popping the dropdown open.
|
|
97
|
+
* @param options.locale - Locale for the `label` field of returned suggestions.
|
|
98
|
+
* @param options.onSelect - Called with the resolved address after a successful
|
|
99
|
+
* `selectSuggestion`.
|
|
100
|
+
*/
|
|
101
|
+
declare function useThaiAddressAutocomplete(options: UseThaiAddressAutocompleteOptions): {
|
|
102
|
+
query: string;
|
|
103
|
+
setQuery: react.Dispatch<react.SetStateAction<string>>;
|
|
104
|
+
setQuerySilent: (value: string) => void;
|
|
105
|
+
suggestions: ThaiAddressSuggestion[];
|
|
106
|
+
isOpen: boolean;
|
|
107
|
+
selectSuggestion: (item: ThaiAddressSuggestion) => ResolvedThaiAddress | null;
|
|
108
|
+
clear: () => void;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export { type ResolvedThaiAddress, type ThaiAddressSuggestion, type TrigramIndex, type UseThaiAddressAutocompleteOptions, useThaiAddressAutocomplete };
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
|
|
3
|
+
type ThaiAddressRecord = {
|
|
4
|
+
provinceId: number;
|
|
5
|
+
provinceNameTh: string;
|
|
6
|
+
provinceNameEn: string;
|
|
7
|
+
amphureId: number;
|
|
8
|
+
amphureNameTh: string;
|
|
9
|
+
amphureNameEn: string;
|
|
10
|
+
tambonId: number;
|
|
11
|
+
tambonNameTh: string;
|
|
12
|
+
tambonNameEn: string;
|
|
13
|
+
zipCode: string;
|
|
14
|
+
};
|
|
15
|
+
type TrigramIndex = {
|
|
16
|
+
map: Map<string, Set<number>>;
|
|
17
|
+
records: ThaiAddressRecord[];
|
|
18
|
+
zipIndex: Map<string, number[]>;
|
|
19
|
+
/**
|
|
20
|
+
* Normalized Thai tambon names, parallel to `records` (same index).
|
|
21
|
+
* Used by the search ranker for exact/prefix match detection without
|
|
22
|
+
* re-normalizing on every query.
|
|
23
|
+
*/
|
|
24
|
+
normTambon: string[];
|
|
25
|
+
/**
|
|
26
|
+
* Normalized English (RTGS) tambon names, parallel to `records`. Needed so
|
|
27
|
+
* romanized queries get the same own-name-beats-parent-name ranking that
|
|
28
|
+
* Thai queries get.
|
|
29
|
+
*/
|
|
30
|
+
normTambonEn: string[];
|
|
31
|
+
/** Record indices grouped by `provinceId`, in insertion order. */
|
|
32
|
+
byProvince: Map<number, number[]>;
|
|
33
|
+
/** Record indices grouped by `amphureId`, in insertion order. */
|
|
34
|
+
byAmphure: Map<number, number[]>;
|
|
35
|
+
};
|
|
36
|
+
/** Which language a formatted label should be rendered in. */
|
|
37
|
+
type AddressLocale = 'th' | 'en';
|
|
38
|
+
type ThaiAddressSuggestion = {
|
|
39
|
+
id: string;
|
|
40
|
+
/** Human-readable label in the requested locale (Thai by default). */
|
|
41
|
+
label: string;
|
|
42
|
+
/** Human-readable label in Thai, regardless of the requested locale. */
|
|
43
|
+
labelTh: string;
|
|
44
|
+
/** Human-readable label in English, regardless of the requested locale. */
|
|
45
|
+
labelEn: string;
|
|
46
|
+
tambon: string;
|
|
47
|
+
tambonEn: string;
|
|
48
|
+
amphure: string;
|
|
49
|
+
amphureEn: string;
|
|
50
|
+
province: string;
|
|
51
|
+
provinceEn: string;
|
|
52
|
+
zipCode: string;
|
|
53
|
+
};
|
|
54
|
+
type ResolvedThaiAddress = {
|
|
55
|
+
tambon: string;
|
|
56
|
+
tambonEn: string;
|
|
57
|
+
amphure: string;
|
|
58
|
+
amphureEn: string;
|
|
59
|
+
province: string;
|
|
60
|
+
provinceEn: string;
|
|
61
|
+
zipCode: string;
|
|
62
|
+
subdistrict: string;
|
|
63
|
+
subdistrictEn: string;
|
|
64
|
+
district: string;
|
|
65
|
+
districtEn: string;
|
|
66
|
+
postalCode: string;
|
|
67
|
+
};
|
|
68
|
+
type UseThaiAddressAutocompleteOptions = {
|
|
69
|
+
index: TrigramIndex;
|
|
70
|
+
limit?: number;
|
|
71
|
+
debounce?: number;
|
|
72
|
+
threshold?: number;
|
|
73
|
+
zipLimit?: number;
|
|
74
|
+
/** Seed value for `query` on first render. Defaults to `''`. */
|
|
75
|
+
initialQuery?: string;
|
|
76
|
+
/** Locale for the `label` field of returned suggestions. Defaults to `'th'`. */
|
|
77
|
+
locale?: AddressLocale;
|
|
78
|
+
/** Called after a suggestion is successfully resolved via `selectSuggestion`. */
|
|
79
|
+
onSelect?: (address: ResolvedThaiAddress) => void;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Headless Thai address autocomplete hook.
|
|
84
|
+
*
|
|
85
|
+
* @param options.index - The TrigramIndex to search against. The hook reads `index`
|
|
86
|
+
* via an internal ref, so a new object reference does NOT restart the debounce
|
|
87
|
+
* timer. When `index` itself changes (e.g. swapped for a custom dataset), any
|
|
88
|
+
* pending query is re-searched immediately with the new index.
|
|
89
|
+
* @param options.limit - Maximum number of suggestions (default 10).
|
|
90
|
+
* @param options.debounce - Debounce delay in ms (default 200).
|
|
91
|
+
* @param options.threshold - Minimum trigram score 0–1 (default 0.4).
|
|
92
|
+
* @param options.zipLimit - Maximum results for all-digit (zip code) queries, passed
|
|
93
|
+
* through to `searchThaiAddress`.
|
|
94
|
+
* @param options.initialQuery - Seed value for `query` on first render. Does NOT
|
|
95
|
+
* trigger a search — useful for pre-filling the input with an already-chosen
|
|
96
|
+
* address (e.g. react-hook-form `defaultValues`) without popping the dropdown open.
|
|
97
|
+
* @param options.locale - Locale for the `label` field of returned suggestions.
|
|
98
|
+
* @param options.onSelect - Called with the resolved address after a successful
|
|
99
|
+
* `selectSuggestion`.
|
|
100
|
+
*/
|
|
101
|
+
declare function useThaiAddressAutocomplete(options: UseThaiAddressAutocompleteOptions): {
|
|
102
|
+
query: string;
|
|
103
|
+
setQuery: react.Dispatch<react.SetStateAction<string>>;
|
|
104
|
+
setQuerySilent: (value: string) => void;
|
|
105
|
+
suggestions: ThaiAddressSuggestion[];
|
|
106
|
+
isOpen: boolean;
|
|
107
|
+
selectSuggestion: (item: ThaiAddressSuggestion) => ResolvedThaiAddress | null;
|
|
108
|
+
clear: () => void;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export { type ResolvedThaiAddress, type ThaiAddressSuggestion, type TrigramIndex, type UseThaiAddressAutocompleteOptions, useThaiAddressAutocomplete };
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
3
|
+
|
|
4
|
+
// src/core/normalizer.ts
|
|
5
|
+
var TONE_MARKS = /[่-๋็์]/g;
|
|
6
|
+
var ADDRESS_PREFIXES = /^(จังหวัด|อำเภอ|ตำบล|แขวง|เขต|จ\.|อ\.|ต\.|ข\.)/;
|
|
7
|
+
function normalizeThaiAddressText(input) {
|
|
8
|
+
if (!input) return "";
|
|
9
|
+
let text = input.trim();
|
|
10
|
+
text = text.replace(ADDRESS_PREFIXES, "").trim();
|
|
11
|
+
text = text.replace(TONE_MARKS, "");
|
|
12
|
+
text = text.toLowerCase();
|
|
13
|
+
return text;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/core/trigrams.ts
|
|
17
|
+
function extractTrigramsNormalized(text) {
|
|
18
|
+
const result = /* @__PURE__ */ new Set();
|
|
19
|
+
if (text.length === 0) return result;
|
|
20
|
+
if (text.length < 3) {
|
|
21
|
+
result.add(text);
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
for (let i = 0; i <= text.length - 3; i++) {
|
|
25
|
+
result.add(text.slice(i, i + 3));
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/core/romanize.ts
|
|
31
|
+
var ALIASES = /* @__PURE__ */ new Map([
|
|
32
|
+
// --- Bangkok khet (districts) — concatenated ("no space") forms ---------
|
|
33
|
+
["phranakhon", "phra nakhon"],
|
|
34
|
+
["nongchok", "nong chok"],
|
|
35
|
+
["bangrak", "bang rak"],
|
|
36
|
+
["bangkhen", "bang khen"],
|
|
37
|
+
["bangkapi", "bang kapi"],
|
|
38
|
+
["pathumwan", "pathum wan"],
|
|
39
|
+
["patumwan", "pathum wan"],
|
|
40
|
+
["pomprap", "pom prap sattru phai"],
|
|
41
|
+
["phrakhanong", "phra khanong"],
|
|
42
|
+
["minburi", "min buri"],
|
|
43
|
+
["latkrabang", "lat krabang"],
|
|
44
|
+
["ladkrabang", "lat krabang"],
|
|
45
|
+
["yannawa", "yan nawa"],
|
|
46
|
+
["phayathai", "phaya thai"],
|
|
47
|
+
["thonburi", "thon buri"],
|
|
48
|
+
["bangkokyai", "bangkok yai"],
|
|
49
|
+
["huaikhwang", "huai khwang"],
|
|
50
|
+
["huaykhwang", "huai khwang"],
|
|
51
|
+
["huaykwang", "huai khwang"],
|
|
52
|
+
["khlongsan", "khlong san"],
|
|
53
|
+
["klongsan", "khlong san"],
|
|
54
|
+
["talingchan", "taling chan"],
|
|
55
|
+
["bangkoknoi", "bangkok noi"],
|
|
56
|
+
["bangkhunthian", "bang khun thian"],
|
|
57
|
+
["phasicharoen", "phasi charoen"],
|
|
58
|
+
["nongkhaem", "nong khaem"],
|
|
59
|
+
["ratburana", "rat burana"],
|
|
60
|
+
["bangphlat", "bang phlat"],
|
|
61
|
+
["dindaeng", "din daeng"],
|
|
62
|
+
["buengkum", "bueng kum"],
|
|
63
|
+
["bangsue", "bang sue"],
|
|
64
|
+
["bangkholaem", "bang kho laem"],
|
|
65
|
+
["khlongtoei", "khlong toei"],
|
|
66
|
+
["klongtoei", "khlong toei"],
|
|
67
|
+
["klongtoey", "khlong toei"],
|
|
68
|
+
["khlongtoey", "khlong toei"],
|
|
69
|
+
["suanluang", "suan luang"],
|
|
70
|
+
["chomthong", "chom thong"],
|
|
71
|
+
["donmueang", "don mueang"],
|
|
72
|
+
["donmuang", "don mueang"],
|
|
73
|
+
["latphrao", "lat phrao"],
|
|
74
|
+
["ladprao", "lat phrao"],
|
|
75
|
+
["lardprao", "lat phrao"],
|
|
76
|
+
["lat prao", "lat phrao"],
|
|
77
|
+
["bangkhae", "bang khae"],
|
|
78
|
+
["laksi", "lak si"],
|
|
79
|
+
["saimai", "sai mai"],
|
|
80
|
+
["khannayao", "khan na yao"],
|
|
81
|
+
["saphansung", "saphan sung"],
|
|
82
|
+
["wangthonglang", "wang thonglang"],
|
|
83
|
+
["khlongsamwa", "khlong sam wa"],
|
|
84
|
+
["klongsamwa", "khlong sam wa"],
|
|
85
|
+
["bangna", "bang na"],
|
|
86
|
+
["thawiwatthana", "thawi watthana"],
|
|
87
|
+
["thungkhru", "thung khru"],
|
|
88
|
+
["bangbon", "bang bon"],
|
|
89
|
+
// --- Bangkok area / street nicknames with a common misspelling ----------
|
|
90
|
+
["sathorn", "sathon"],
|
|
91
|
+
["wattana", "watthana"],
|
|
92
|
+
["silom", "si lom"],
|
|
93
|
+
// --- Provinces and other high-traffic areas ------------------------------
|
|
94
|
+
["krungthep", "bangkok"],
|
|
95
|
+
["korat", "nakhon ratchasima"],
|
|
96
|
+
["nakornratchasima", "nakhon ratchasima"],
|
|
97
|
+
["chiangmai", "chiang mai"],
|
|
98
|
+
["chiangrai", "chiang rai"],
|
|
99
|
+
["pathumthani", "pathum thani"],
|
|
100
|
+
["patumthani", "pathum thani"],
|
|
101
|
+
["samutprakan", "samut prakan"],
|
|
102
|
+
["samutsakhon", "samut sakhon"],
|
|
103
|
+
["samutsongkhram", "samut songkhram"],
|
|
104
|
+
["nakhonpathom", "nakhon pathom"],
|
|
105
|
+
["nakhonsawan", "nakhon sawan"],
|
|
106
|
+
["nakhonsithammarat", "nakhon si thammarat"],
|
|
107
|
+
["prachuapkhirikhan", "prachuap khiri khan"],
|
|
108
|
+
["huahin", "hua hin"],
|
|
109
|
+
["hatyai", "hat yai"],
|
|
110
|
+
["hadyai", "hat yai"],
|
|
111
|
+
["had yai", "hat yai"],
|
|
112
|
+
["lopburi", "loburi"],
|
|
113
|
+
["lop buri", "loburi"],
|
|
114
|
+
["buengkan", "buogkan"],
|
|
115
|
+
["bungkan", "buogkan"],
|
|
116
|
+
["bung kan", "buogkan"],
|
|
117
|
+
["udonthani", "udon thani"],
|
|
118
|
+
["khonkaen", "khon kaen"],
|
|
119
|
+
["suphanburi", "suphan buri"],
|
|
120
|
+
["nongkhai", "nong khai"],
|
|
121
|
+
["buriram", "buri ram"]
|
|
122
|
+
]);
|
|
123
|
+
var NOISE_WORDS_RE = /\b(?:sub[- ]?district|district|province|changwat|amphoe|amphur)\b/g;
|
|
124
|
+
var WHITESPACE_RE = /\s+/g;
|
|
125
|
+
function cleanup(text) {
|
|
126
|
+
const stripped = text.replace(NOISE_WORDS_RE, " ").replace(WHITESPACE_RE, " ").trim();
|
|
127
|
+
return stripped.length > 0 ? stripped : text;
|
|
128
|
+
}
|
|
129
|
+
function applyRomanizationAliases(normalized) {
|
|
130
|
+
if (!normalized) return normalized;
|
|
131
|
+
const direct = ALIASES.get(normalized);
|
|
132
|
+
if (direct !== void 0) return direct;
|
|
133
|
+
const cleaned = cleanup(normalized);
|
|
134
|
+
if (cleaned === normalized) return normalized;
|
|
135
|
+
const aliasedCleaned = ALIASES.get(cleaned);
|
|
136
|
+
return aliasedCleaned !== void 0 ? aliasedCleaned : cleaned;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/core/search.ts
|
|
140
|
+
var ZIP_CODE_RE = /^\d+$/;
|
|
141
|
+
var HAS_LATIN_RE = /[a-z]/i;
|
|
142
|
+
var TH_COLLATOR = new Intl.Collator("th");
|
|
143
|
+
function compareZip(a, b) {
|
|
144
|
+
if (a < b) return -1;
|
|
145
|
+
if (a > b) return 1;
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
function sortZipMatches(matches, exactZip) {
|
|
149
|
+
matches.sort((a, b) => {
|
|
150
|
+
if (a.zipCode === exactZip && b.zipCode !== exactZip) return -1;
|
|
151
|
+
if (b.zipCode === exactZip && a.zipCode !== exactZip) return 1;
|
|
152
|
+
return compareZip(a.zipCode, b.zipCode);
|
|
153
|
+
});
|
|
154
|
+
return matches;
|
|
155
|
+
}
|
|
156
|
+
function lookupByZipCode(index, zip, options) {
|
|
157
|
+
if (!index || !zip) return [];
|
|
158
|
+
const normalized = zip.trim();
|
|
159
|
+
if (!ZIP_CODE_RE.test(normalized) || normalized.length < 2) return [];
|
|
160
|
+
const zipLimit = options?.zipLimit ?? Infinity;
|
|
161
|
+
const matches = [];
|
|
162
|
+
for (const [z, indices] of index.zipIndex) {
|
|
163
|
+
if (z.startsWith(normalized)) {
|
|
164
|
+
for (const idx of indices) matches.push(index.records[idx]);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
sortZipMatches(matches, normalized);
|
|
168
|
+
return matches.slice(0, zipLimit);
|
|
169
|
+
}
|
|
170
|
+
function rankAgainstName(name, query) {
|
|
171
|
+
if (name === void 0 || name.length === 0) return 0;
|
|
172
|
+
if (name === query) return 3;
|
|
173
|
+
if (name.startsWith(query)) return 2;
|
|
174
|
+
if (name.includes(query)) return 1;
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
function computeMatchRank(index, idx, query) {
|
|
178
|
+
const th = rankAgainstName(index.normTambon?.[idx], query);
|
|
179
|
+
if (th === 3) return 3;
|
|
180
|
+
const en = rankAgainstName(index.normTambonEn?.[idx], query);
|
|
181
|
+
return en > th ? en : th;
|
|
182
|
+
}
|
|
183
|
+
function searchThaiAddress(index, query, options) {
|
|
184
|
+
const limit = options?.limit ?? 10;
|
|
185
|
+
const threshold = options?.threshold ?? 0.4;
|
|
186
|
+
if (!index || !query) return [];
|
|
187
|
+
if (query.length > 1e3) return [];
|
|
188
|
+
const normalized = normalizeThaiAddressText(query);
|
|
189
|
+
if (normalized.length === 0) return [];
|
|
190
|
+
if (normalized.length > 300) return [];
|
|
191
|
+
if (ZIP_CODE_RE.test(normalized)) {
|
|
192
|
+
if (normalized.length < 2) return [];
|
|
193
|
+
return lookupByZipCode(index, normalized, {
|
|
194
|
+
zipLimit: options?.zipLimit ?? Infinity
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (normalized.length < 3) return [];
|
|
198
|
+
const searchText = options?.romanizationAliases !== false && HAS_LATIN_RE.test(normalized) ? applyRomanizationAliases(normalized) : normalized;
|
|
199
|
+
const queryTrigrams = extractTrigramsNormalized(searchText);
|
|
200
|
+
const hits = /* @__PURE__ */ new Map();
|
|
201
|
+
for (const trigram of queryTrigrams) {
|
|
202
|
+
const candidates = index.map.get(trigram);
|
|
203
|
+
if (!candidates) continue;
|
|
204
|
+
for (const idx of candidates) {
|
|
205
|
+
hits.set(idx, (hits.get(idx) ?? 0) + 1);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const scored = [];
|
|
209
|
+
for (const [idx, count] of hits) {
|
|
210
|
+
const score = count / queryTrigrams.size;
|
|
211
|
+
if (score >= threshold) {
|
|
212
|
+
scored.push({ idx, score, matchRank: computeMatchRank(index, idx, searchText) });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
scored.sort((a, b) => {
|
|
216
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
217
|
+
return b.matchRank - a.matchRank;
|
|
218
|
+
});
|
|
219
|
+
const windowSize = Math.max(limit * 4, 50);
|
|
220
|
+
const window = scored.slice(0, windowSize);
|
|
221
|
+
window.sort((a, b) => {
|
|
222
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
223
|
+
if (b.matchRank !== a.matchRank) return b.matchRank - a.matchRank;
|
|
224
|
+
const ra = index.records[a.idx];
|
|
225
|
+
const rb = index.records[b.idx];
|
|
226
|
+
return TH_COLLATOR.compare(ra.provinceNameTh, rb.provinceNameTh) || TH_COLLATOR.compare(ra.amphureNameTh, rb.amphureNameTh) || TH_COLLATOR.compare(ra.tambonNameTh, rb.tambonNameTh);
|
|
227
|
+
});
|
|
228
|
+
return window.slice(0, limit).map(({ idx }) => index.records[idx]);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// src/core/formatter.ts
|
|
232
|
+
function formatThaiAddressSuggestion(record, options) {
|
|
233
|
+
const labelTh = `${record.tambonNameTh} > ${record.amphureNameTh} > ${record.provinceNameTh} ${record.zipCode}`;
|
|
234
|
+
const labelEn = `${record.tambonNameEn} > ${record.amphureNameEn} > ${record.provinceNameEn} ${record.zipCode}`;
|
|
235
|
+
return {
|
|
236
|
+
id: String(record.tambonId),
|
|
237
|
+
label: options?.locale === "en" ? labelEn : labelTh,
|
|
238
|
+
labelTh,
|
|
239
|
+
labelEn,
|
|
240
|
+
tambon: record.tambonNameTh,
|
|
241
|
+
tambonEn: record.tambonNameEn,
|
|
242
|
+
amphure: record.amphureNameTh,
|
|
243
|
+
amphureEn: record.amphureNameEn,
|
|
244
|
+
province: record.provinceNameTh,
|
|
245
|
+
provinceEn: record.provinceNameEn,
|
|
246
|
+
zipCode: record.zipCode
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// src/core/resolver.ts
|
|
251
|
+
function resolveThaiAddress(record) {
|
|
252
|
+
return {
|
|
253
|
+
tambon: record.tambonNameTh,
|
|
254
|
+
tambonEn: record.tambonNameEn,
|
|
255
|
+
amphure: record.amphureNameTh,
|
|
256
|
+
amphureEn: record.amphureNameEn,
|
|
257
|
+
province: record.provinceNameTh,
|
|
258
|
+
provinceEn: record.provinceNameEn,
|
|
259
|
+
zipCode: record.zipCode,
|
|
260
|
+
subdistrict: record.tambonNameTh,
|
|
261
|
+
subdistrictEn: record.tambonNameEn,
|
|
262
|
+
district: record.amphureNameTh,
|
|
263
|
+
districtEn: record.amphureNameEn,
|
|
264
|
+
postalCode: record.zipCode
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// src/react/useThaiAddressAutocomplete.ts
|
|
269
|
+
function useThaiAddressAutocomplete(options) {
|
|
270
|
+
const {
|
|
271
|
+
index,
|
|
272
|
+
limit = 10,
|
|
273
|
+
debounce: debounceMs = 200,
|
|
274
|
+
threshold = 0.4,
|
|
275
|
+
zipLimit,
|
|
276
|
+
initialQuery = "",
|
|
277
|
+
locale,
|
|
278
|
+
onSelect
|
|
279
|
+
} = options;
|
|
280
|
+
const indexRef = useRef(index);
|
|
281
|
+
indexRef.current = index;
|
|
282
|
+
const [query, setQuery] = useState(initialQuery);
|
|
283
|
+
const [suggestions, setSuggestions] = useState([]);
|
|
284
|
+
const timerRef = useRef(null);
|
|
285
|
+
const matchedRecordsMapRef = useRef(/* @__PURE__ */ new Map());
|
|
286
|
+
const suppressNextSearchRef = useRef(initialQuery.length > 0);
|
|
287
|
+
const isFirstIndexEffectRunRef = useRef(true);
|
|
288
|
+
const runSearch = useCallback(
|
|
289
|
+
(q, idx) => {
|
|
290
|
+
const records = searchThaiAddress(idx, q, { limit, threshold, zipLimit });
|
|
291
|
+
const formatted = records.map((record) => formatThaiAddressSuggestion(record, { locale }));
|
|
292
|
+
const recordMap = /* @__PURE__ */ new Map();
|
|
293
|
+
for (let i = 0; i < records.length; i++) {
|
|
294
|
+
recordMap.set(formatted[i].id, records[i]);
|
|
295
|
+
}
|
|
296
|
+
matchedRecordsMapRef.current = recordMap;
|
|
297
|
+
setSuggestions(formatted);
|
|
298
|
+
},
|
|
299
|
+
[limit, threshold, zipLimit, locale]
|
|
300
|
+
);
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
303
|
+
if (query.length === 0) {
|
|
304
|
+
setSuggestions([]);
|
|
305
|
+
matchedRecordsMapRef.current = /* @__PURE__ */ new Map();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (suppressNextSearchRef.current) {
|
|
309
|
+
suppressNextSearchRef.current = false;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
timerRef.current = setTimeout(() => {
|
|
313
|
+
runSearch(query, indexRef.current);
|
|
314
|
+
}, debounceMs);
|
|
315
|
+
return () => {
|
|
316
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
317
|
+
};
|
|
318
|
+
}, [query, limit, threshold, debounceMs, zipLimit, locale, runSearch]);
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
if (isFirstIndexEffectRunRef.current) {
|
|
321
|
+
isFirstIndexEffectRunRef.current = false;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (query.length === 0) return;
|
|
325
|
+
if (suppressNextSearchRef.current) {
|
|
326
|
+
suppressNextSearchRef.current = false;
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
330
|
+
runSearch(query, index);
|
|
331
|
+
}, [index]);
|
|
332
|
+
const selectSuggestion = useCallback(
|
|
333
|
+
(item) => {
|
|
334
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
335
|
+
const record = matchedRecordsMapRef.current.get(item.id);
|
|
336
|
+
if (!record) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
setSuggestions([]);
|
|
340
|
+
matchedRecordsMapRef.current = /* @__PURE__ */ new Map();
|
|
341
|
+
const resolved = resolveThaiAddress(record);
|
|
342
|
+
onSelect?.(resolved);
|
|
343
|
+
return resolved;
|
|
344
|
+
},
|
|
345
|
+
[onSelect]
|
|
346
|
+
);
|
|
347
|
+
const clear = useCallback(() => {
|
|
348
|
+
setQuery("");
|
|
349
|
+
setSuggestions([]);
|
|
350
|
+
matchedRecordsMapRef.current = /* @__PURE__ */ new Map();
|
|
351
|
+
}, []);
|
|
352
|
+
const setQuerySilent = useCallback((value) => {
|
|
353
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
354
|
+
if (value.length > 0) suppressNextSearchRef.current = true;
|
|
355
|
+
setSuggestions([]);
|
|
356
|
+
matchedRecordsMapRef.current = /* @__PURE__ */ new Map();
|
|
357
|
+
setQuery(value);
|
|
358
|
+
}, []);
|
|
359
|
+
return {
|
|
360
|
+
query,
|
|
361
|
+
setQuery,
|
|
362
|
+
setQuerySilent,
|
|
363
|
+
suggestions,
|
|
364
|
+
isOpen: query.length > 0 && suggestions.length > 0,
|
|
365
|
+
selectSuggestion,
|
|
366
|
+
clear
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export { useThaiAddressAutocomplete };
|
|
371
|
+
//# sourceMappingURL=react.js.map
|
|
372
|
+
//# sourceMappingURL=react.js.map
|