@meterapp/vehicle-db 2.5.0 → 2.6.0
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 +65 -4
- package/dist/index.d.ts +9 -2
- package/dist/index.js +17 -3
- package/dist/search.d.ts +47 -0
- package/dist/search.js +482 -0
- package/package.json +26 -2
package/README.md
CHANGED
|
@@ -2,8 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
An offline, international vehicle make/model catalog for Node.js and TypeScript. The package combines U.S. model-year data, the UK registered fleet, Asian-origin vehicles registered in New Zealand, Malaysian registration transactions, and an Indian manufacturer catalog into one small, zero-dependency API. It never makes runtime network requests.
|
|
4
4
|
|
|
5
|
+
The current snapshot spans **1990–2026** and includes **1,145 makes**, **14,841 model names**, and **132,491 deduplicated model-year entries** from **5 data sources**.
|
|
6
|
+
|
|
5
7
|
The catalog covers seven vehicle types: **Motorcycle**, **Passenger Car**, **Truck**, **Bus**, **Multipurpose Passenger Vehicle (MPV)**, **Auto Rickshaw**, and **Other Vehicle**. The Asia-Pacific sources add Japanese domestic and kei models, Chinese EVs, Indian and Korean vehicles, Southeast Asian makes such as Perodua and Proton, and additional motorcycles and commercial vehicles.
|
|
6
8
|
|
|
9
|
+
## Demo and playground
|
|
10
|
+
|
|
11
|
+
Want to see the API in action? Browse [Car Images](https://car-imgs.vercel.app), an example app built with this package, or explore the data directly in the [interactive playground](https://car-imgs.vercel.app/playground).
|
|
12
|
+
|
|
7
13
|
## Install
|
|
8
14
|
|
|
9
15
|
```bash
|
|
@@ -75,9 +81,51 @@ const malaysiaMakes = getMakes({
|
|
|
75
81
|
// Existing queries remain valid.
|
|
76
82
|
const toyota = getMakes({ year: 2024 }).find((make) => make.makeName === "TOYOTA");
|
|
77
83
|
const toyotaModels = getModels({ makeId: toyota!.makeId, year: 2024 });
|
|
78
|
-
const years = getAvailableYears(
|
|
84
|
+
const years = getAvailableYears({
|
|
85
|
+
makeId: toyota!.makeId,
|
|
86
|
+
vehicleTypeId: 2,
|
|
87
|
+
sourceId: "nhtsa-vpic",
|
|
88
|
+
});
|
|
79
89
|
```
|
|
80
90
|
|
|
91
|
+
## Driver search and autocomplete
|
|
92
|
+
|
|
93
|
+
The optional, zero-dependency search entry point turns free-form driver input into canonical catalog candidates. Its index is built lazily on the first search, and matching runs entirely in memory without network requests.
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
import { searchVehicles } from "@meterapp/vehicle-db/search";
|
|
97
|
+
|
|
98
|
+
const suggestions = searchVehicles("2020 toy cam", {
|
|
99
|
+
sourceId: "nhtsa-vpic",
|
|
100
|
+
vehicleTypeId: 2,
|
|
101
|
+
limit: 10,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const camry = suggestions.find(
|
|
105
|
+
(result) => result.kind === "model" && result.modelName === "Camry",
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (camry?.kind === "model") {
|
|
109
|
+
// A query containing a year has one directly selectable variant.
|
|
110
|
+
const selection = camry.variants[0];
|
|
111
|
+
// Persist year, makeId, modelId, and vehicleTypeId — not display text alone.
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Search accepts year, make, and model tokens in any order. It recognizes common punctuation variants such as `F-150`/`f150` and `CR-V`/`crv`, a small set of make aliases such as `VW` and `Chevy`, and conservative misspellings. Results are ranked deterministically by exact, prefix, token, substring, then fuzzy match. Fuzzy matching is only used when no lexical result exists.
|
|
116
|
+
|
|
117
|
+
Queries without a year group a canonical make/model/type across all available years rather than returning duplicate suggestions for every year. Each model result contains `variants`, which provides the preferred catalog `modelId` for every selectable year.
|
|
118
|
+
|
|
119
|
+
For the simplest driver experience:
|
|
120
|
+
|
|
121
|
+
1. Infer the relevant market and pass its `sourceId`; year semantics differ by source.
|
|
122
|
+
2. Offer one field labelled “Search year, make, or model” and begin suggesting after two characters.
|
|
123
|
+
3. If the driver enters only a year, use `getMakes({ year, ... })` for the next step; a year-only search intentionally returns no arbitrary alphabetical suggestions.
|
|
124
|
+
4. On empty input, show application-owned recent vehicles. The catalog intentionally does not pretend that record frequency is vehicle popularity.
|
|
125
|
+
5. Use the manual fallback Year → Make → Model. `getAvailableYears(options)` supports every step.
|
|
126
|
+
|
|
127
|
+
Applications with regional telemetry can request more candidates and rerank **within the same `matchKind`**. Text quality and market compatibility should remain ahead of behavioral popularity so an exact result never loses to an unrelated popular vehicle.
|
|
128
|
+
|
|
81
129
|
## API
|
|
82
130
|
|
|
83
131
|
### `getDataSources(): DataSource[]`
|
|
@@ -133,10 +181,11 @@ interface Make {
|
|
|
133
181
|
|
|
134
182
|
### `getModels(options?): Model[]`
|
|
135
183
|
|
|
136
|
-
Returns models, optionally filtered by `year`, `vehicleTypeId`, `makeId`, and/or `sourceId`. `sourceIds` preserves provenance when equivalent records appear in more than one source.
|
|
184
|
+
Returns models, optionally filtered by `year`, `vehicleTypeId`, `makeId`, `modelId`, and/or `sourceId`. `sourceIds` preserves provenance when equivalent records appear in more than one source.
|
|
137
185
|
|
|
138
186
|
```typescript
|
|
139
187
|
getModels({ makeId: 474, year: 2024 });
|
|
188
|
+
getModels({ modelId: 2469, year: 2024 });
|
|
140
189
|
getModels({ year: 2024, vehicleTypeId: 1 });
|
|
141
190
|
getModels({ year: 2024, vehicleTypeId: 5, sourceId: "uk-dft-vehicle-licensing" });
|
|
142
191
|
```
|
|
@@ -153,9 +202,21 @@ interface Model {
|
|
|
153
202
|
}
|
|
154
203
|
```
|
|
155
204
|
|
|
156
|
-
### `getAvailableYears(): number[]`
|
|
205
|
+
### `getAvailableYears(options?): number[]`
|
|
206
|
+
|
|
207
|
+
Returns years present in the combined catalog, optionally filtered by `makeId`, `modelId`, `vehicleTypeId`, and/or `sourceId`. Results are sorted ascending.
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
getAvailableYears();
|
|
211
|
+
getAvailableYears({ makeId: 448, vehicleTypeId: 2, sourceId: "nhtsa-vpic" });
|
|
212
|
+
getAvailableYears({ modelId: 2469, sourceId: "nhtsa-vpic" });
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### `searchVehicles(query, options?): VehicleSearchResult[]`
|
|
216
|
+
|
|
217
|
+
Import from `@meterapp/vehicle-db/search`. Returns deterministic make and grouped model candidates for free-form input. Options include `year`, `vehicleTypeId`, `sourceId`, `limit` (default 10, maximum 100), and `fuzzy` (default `true`).
|
|
157
218
|
|
|
158
|
-
|
|
219
|
+
Model results contain `years` and a year-specific `variants` array. Make results intentionally omit models, allowing applications to transition into the existing `getModels` flow after the driver chooses a make.
|
|
159
220
|
|
|
160
221
|
## Data sources
|
|
161
222
|
|
package/dist/index.d.ts
CHANGED
|
@@ -39,6 +39,13 @@ export interface GetModelsOptions {
|
|
|
39
39
|
year?: number;
|
|
40
40
|
vehicleTypeId?: number;
|
|
41
41
|
makeId?: number;
|
|
42
|
+
modelId?: number;
|
|
43
|
+
sourceId?: string;
|
|
44
|
+
}
|
|
45
|
+
export interface GetAvailableYearsOptions {
|
|
46
|
+
vehicleTypeId?: number;
|
|
47
|
+
makeId?: number;
|
|
48
|
+
modelId?: number;
|
|
42
49
|
sourceId?: string;
|
|
43
50
|
}
|
|
44
51
|
/**
|
|
@@ -58,6 +65,6 @@ export declare function getMakes(options?: GetMakesOptions): Make[];
|
|
|
58
65
|
*/
|
|
59
66
|
export declare function getModels(options?: GetModelsOptions): Model[];
|
|
60
67
|
/**
|
|
61
|
-
*
|
|
68
|
+
* Gets available years, optionally filtered by vehicle identity and source.
|
|
62
69
|
*/
|
|
63
|
-
export declare function getAvailableYears(): number[];
|
|
70
|
+
export declare function getAvailableYears(options?: GetAvailableYearsOptions): number[];
|
package/dist/index.js
CHANGED
|
@@ -105,7 +105,7 @@ function getMakes(options = {}) {
|
|
|
105
105
|
function getModels(options = {}) {
|
|
106
106
|
const makeMap = getMakeMap();
|
|
107
107
|
const typeMap = getTypeMap();
|
|
108
|
-
const { year, vehicleTypeId, makeId, sourceId } = options;
|
|
108
|
+
const { year, vehicleTypeId, makeId, modelId, sourceId } = options;
|
|
109
109
|
const sourceMask = sourceId == null ? undefined : getSourceMaskMap().get(sourceId);
|
|
110
110
|
if (sourceId != null && sourceMask == null)
|
|
111
111
|
return [];
|
|
@@ -115,6 +115,8 @@ function getModels(options = {}) {
|
|
|
115
115
|
continue;
|
|
116
116
|
if (makeId != null && m[1] !== makeId)
|
|
117
117
|
continue;
|
|
118
|
+
if (modelId != null && m[2] !== modelId)
|
|
119
|
+
continue;
|
|
118
120
|
if (vehicleTypeId != null && m[4] !== vehicleTypeId)
|
|
119
121
|
continue;
|
|
120
122
|
if (sourceMask != null && (m[5] & sourceMask) === 0)
|
|
@@ -133,11 +135,23 @@ function getModels(options = {}) {
|
|
|
133
135
|
a.vehicleTypeId - b.vehicleTypeId);
|
|
134
136
|
}
|
|
135
137
|
/**
|
|
136
|
-
*
|
|
138
|
+
* Gets available years, optionally filtered by vehicle identity and source.
|
|
137
139
|
*/
|
|
138
|
-
function getAvailableYears() {
|
|
140
|
+
function getAvailableYears(options = {}) {
|
|
141
|
+
const { vehicleTypeId, makeId, modelId, sourceId } = options;
|
|
142
|
+
const sourceMask = sourceId == null ? undefined : getSourceMaskMap().get(sourceId);
|
|
143
|
+
if (sourceId != null && sourceMask == null)
|
|
144
|
+
return [];
|
|
139
145
|
const years = new Set();
|
|
140
146
|
for (const m of data_1.default.models) {
|
|
147
|
+
if (makeId != null && m[1] !== makeId)
|
|
148
|
+
continue;
|
|
149
|
+
if (modelId != null && m[2] !== modelId)
|
|
150
|
+
continue;
|
|
151
|
+
if (vehicleTypeId != null && m[4] !== vehicleTypeId)
|
|
152
|
+
continue;
|
|
153
|
+
if (sourceMask != null && (m[5] & sourceMask) === 0)
|
|
154
|
+
continue;
|
|
141
155
|
years.add(m[0]);
|
|
142
156
|
}
|
|
143
157
|
return [...years].sort((a, b) => a - b);
|
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type VehicleSearchMatchKind = "exact" | "prefix" | "token" | "contains" | "fuzzy";
|
|
2
|
+
export interface SearchVehiclesOptions {
|
|
3
|
+
/** Restrict results to one catalog year. A year in the query is detected automatically. */
|
|
4
|
+
year?: number;
|
|
5
|
+
vehicleTypeId?: number;
|
|
6
|
+
sourceId?: string;
|
|
7
|
+
/** Maximum number of results. Defaults to 10 and is capped at 100. */
|
|
8
|
+
limit?: number;
|
|
9
|
+
/** Enables conservative typo matching. Defaults to true. */
|
|
10
|
+
fuzzy?: boolean;
|
|
11
|
+
}
|
|
12
|
+
interface VehicleSearchResultBase {
|
|
13
|
+
makeId: number;
|
|
14
|
+
makeName: string;
|
|
15
|
+
/** Available years after applying the supplied filters, sorted ascending. */
|
|
16
|
+
years: number[];
|
|
17
|
+
sourceIds: string[];
|
|
18
|
+
matchKind: VehicleSearchMatchKind;
|
|
19
|
+
}
|
|
20
|
+
export interface VehicleMakeSearchResult extends VehicleSearchResultBase {
|
|
21
|
+
kind: "make";
|
|
22
|
+
}
|
|
23
|
+
export interface VehicleSearchVariant {
|
|
24
|
+
year: number;
|
|
25
|
+
/** The preferred catalog model ID for this canonical make/model/type/year. */
|
|
26
|
+
modelId: number;
|
|
27
|
+
sourceIds: string[];
|
|
28
|
+
}
|
|
29
|
+
export interface VehicleModelSearchResult extends VehicleSearchResultBase {
|
|
30
|
+
kind: "model";
|
|
31
|
+
modelName: string;
|
|
32
|
+
vehicleTypeId: number;
|
|
33
|
+
vehicleTypeName: string;
|
|
34
|
+
/** One directly selectable catalog identity per available year. */
|
|
35
|
+
variants: VehicleSearchVariant[];
|
|
36
|
+
}
|
|
37
|
+
export type VehicleSearchResult = VehicleMakeSearchResult | VehicleModelSearchResult;
|
|
38
|
+
/**
|
|
39
|
+
* Finds canonical make and model candidates for a driver-entered query.
|
|
40
|
+
*
|
|
41
|
+
* Matching is deterministic and popularity-neutral: exact, prefix, token,
|
|
42
|
+
* substring, then conservative typo matching. Applications can request a
|
|
43
|
+
* larger candidate set and rerank within the returned `matchKind` using local
|
|
44
|
+
* recents or regional telemetry.
|
|
45
|
+
*/
|
|
46
|
+
export declare function searchVehicles(query: string, options?: SearchVehiclesOptions): VehicleSearchResult[];
|
|
47
|
+
export {};
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.searchVehicles = searchVehicles;
|
|
7
|
+
const data_1 = __importDefault(require("./data"));
|
|
8
|
+
const MATCH_RANK = {
|
|
9
|
+
exact: 0,
|
|
10
|
+
prefix: 1,
|
|
11
|
+
token: 2,
|
|
12
|
+
contains: 3,
|
|
13
|
+
fuzzy: 4,
|
|
14
|
+
};
|
|
15
|
+
// These are search-only colloquialisms. They do not rewrite catalog identities.
|
|
16
|
+
const MAKE_SEARCH_ALIASES = new Map([
|
|
17
|
+
["alfa romeo", ["alfa"]],
|
|
18
|
+
["bmw", ["bimmer", "beemer"]],
|
|
19
|
+
["chevrolet", ["chevy"]],
|
|
20
|
+
["general motors", ["gm"]],
|
|
21
|
+
["harley davidson", ["harley"]],
|
|
22
|
+
["mercedes benz", ["mercedes", "benz", "merc"]],
|
|
23
|
+
["volkswagen", ["vw"]],
|
|
24
|
+
]);
|
|
25
|
+
const sourceMaskById = new Map(data_1.default.sources.map((source, index) => [source.source_id, 2 ** index]));
|
|
26
|
+
const vehicleTypeNameById = new Map(data_1.default.vehicleTypes.map((type) => [type.vehicle_type_id, type.vehicle_type_name]));
|
|
27
|
+
let _searchIndex = null;
|
|
28
|
+
/**
|
|
29
|
+
* Finds canonical make and model candidates for a driver-entered query.
|
|
30
|
+
*
|
|
31
|
+
* Matching is deterministic and popularity-neutral: exact, prefix, token,
|
|
32
|
+
* substring, then conservative typo matching. Applications can request a
|
|
33
|
+
* larger candidate set and rerank within the returned `matchKind` using local
|
|
34
|
+
* recents or regional telemetry.
|
|
35
|
+
*/
|
|
36
|
+
function searchVehicles(query, options = {}) {
|
|
37
|
+
if (options.year != null && !Number.isInteger(options.year))
|
|
38
|
+
return [];
|
|
39
|
+
const parsedQueries = parseQueries(query, options.year);
|
|
40
|
+
if (parsedQueries.length === 0)
|
|
41
|
+
return [];
|
|
42
|
+
const sourceMask = options.sourceId == null ? undefined : sourceMaskById.get(options.sourceId);
|
|
43
|
+
if (options.sourceId != null && sourceMask == null)
|
|
44
|
+
return [];
|
|
45
|
+
const limit = normalizeLimit(options.limit);
|
|
46
|
+
if (limit === 0)
|
|
47
|
+
return [];
|
|
48
|
+
const index = getSearchIndex();
|
|
49
|
+
const rankedByKey = collectQueryInterpretations(index, parsedQueries, options, sourceMask, false);
|
|
50
|
+
preferUnmodifiedInterpretation(rankedByKey);
|
|
51
|
+
// Typo matching is a fallback, not filler after a good lexical match. This
|
|
52
|
+
// keeps an exact vehicle from being followed by unrelated near-spellings.
|
|
53
|
+
if (options.fuzzy !== false && rankedByKey.size === 0) {
|
|
54
|
+
for (const [key, result] of collectQueryInterpretations(index, parsedQueries, options, sourceMask, true)) {
|
|
55
|
+
rankedByKey.set(key, result);
|
|
56
|
+
}
|
|
57
|
+
preferUnmodifiedInterpretation(rankedByKey);
|
|
58
|
+
}
|
|
59
|
+
const ranked = [...rankedByKey.values()];
|
|
60
|
+
ranked.sort(compareRankedResults);
|
|
61
|
+
return ranked.slice(0, limit).map(({ result }) => result);
|
|
62
|
+
}
|
|
63
|
+
function preferUnmodifiedInterpretation(results) {
|
|
64
|
+
if (![...results.values()].some((result) => result.interpretationRank === 0))
|
|
65
|
+
return;
|
|
66
|
+
for (const [key, result] of results) {
|
|
67
|
+
if (result.interpretationRank > 0)
|
|
68
|
+
results.delete(key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function collectQueryInterpretations(index, parsedQueries, options, sourceMask, fuzzyOnly) {
|
|
72
|
+
const rankedByKey = new Map();
|
|
73
|
+
for (const parsed of parsedQueries) {
|
|
74
|
+
const interpretationResults = [];
|
|
75
|
+
collectMatches(index, parsed.text, parsed.year, parsed.interpretationRank, options, sourceMask, fuzzyOnly, interpretationResults, new Set());
|
|
76
|
+
for (const result of interpretationResults) {
|
|
77
|
+
const existing = rankedByKey.get(result.key);
|
|
78
|
+
if (!existing || compareRankedResults(result, existing) < 0) {
|
|
79
|
+
rankedByKey.set(result.key, result);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return rankedByKey;
|
|
84
|
+
}
|
|
85
|
+
function collectMatches(index, query, year, interpretationRank, options, sourceMask, fuzzyOnly, results, includedKeys) {
|
|
86
|
+
for (const make of index.makes) {
|
|
87
|
+
const key = `make\u0000${make.makeId}`;
|
|
88
|
+
if (includedKeys.has(key))
|
|
89
|
+
continue;
|
|
90
|
+
const match = matchForms(query, make.forms, fuzzyOnly);
|
|
91
|
+
if (!match || (fuzzyOnly && match.kind !== "fuzzy"))
|
|
92
|
+
continue;
|
|
93
|
+
const availability = selectMakeAvailability(make.rows, year, options.vehicleTypeId, sourceMask);
|
|
94
|
+
if (availability.years.length === 0)
|
|
95
|
+
continue;
|
|
96
|
+
includedKeys.add(key);
|
|
97
|
+
results.push({
|
|
98
|
+
key,
|
|
99
|
+
rank: match.rank,
|
|
100
|
+
interpretationRank,
|
|
101
|
+
closeness: match.closeness,
|
|
102
|
+
result: {
|
|
103
|
+
kind: "make",
|
|
104
|
+
makeId: make.makeId,
|
|
105
|
+
makeName: make.makeName,
|
|
106
|
+
years: availability.years,
|
|
107
|
+
sourceIds: getSourceIds(availability.sourceMask),
|
|
108
|
+
matchKind: match.kind,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
for (const model of index.models) {
|
|
113
|
+
if (options.vehicleTypeId != null && model.vehicleTypeId !== options.vehicleTypeId)
|
|
114
|
+
continue;
|
|
115
|
+
const key = `model\u0000${model.makeId}\u0000${model.modelForms[0].compact}\u0000${model.vehicleTypeId}`;
|
|
116
|
+
if (includedKeys.has(key))
|
|
117
|
+
continue;
|
|
118
|
+
const modelMatch = matchForms(query, model.modelForms, fuzzyOnly);
|
|
119
|
+
const makeMatch = matchForms(query, model.makeForms, fuzzyOnly);
|
|
120
|
+
const match = modelMatch ?? (makeMatch ? undefined : matchForms(query, model.combinedForms, fuzzyOnly));
|
|
121
|
+
if (!match || (fuzzyOnly && match.kind !== "fuzzy"))
|
|
122
|
+
continue;
|
|
123
|
+
const variants = selectModelVariants(model.rows, year, sourceMask);
|
|
124
|
+
if (variants.length === 0)
|
|
125
|
+
continue;
|
|
126
|
+
const representative = variants.reduce((best, variant) => compareRepresentativeVariants(variant, best) < 0 ? variant : best);
|
|
127
|
+
const combinedSourceMask = variants.reduce((mask, variant) => mask | variant.sourceMask, 0);
|
|
128
|
+
includedKeys.add(key);
|
|
129
|
+
results.push({
|
|
130
|
+
key,
|
|
131
|
+
rank: match.rank,
|
|
132
|
+
interpretationRank,
|
|
133
|
+
closeness: match.closeness,
|
|
134
|
+
result: {
|
|
135
|
+
kind: "model",
|
|
136
|
+
makeId: model.makeId,
|
|
137
|
+
makeName: model.makeName,
|
|
138
|
+
modelName: representative.modelName,
|
|
139
|
+
vehicleTypeId: model.vehicleTypeId,
|
|
140
|
+
vehicleTypeName: model.vehicleTypeName,
|
|
141
|
+
years: variants.map((variant) => variant.year),
|
|
142
|
+
sourceIds: getSourceIds(combinedSourceMask),
|
|
143
|
+
matchKind: match.kind,
|
|
144
|
+
variants: variants.map(({ year: variantYear, modelId, sourceIds }) => ({
|
|
145
|
+
year: variantYear,
|
|
146
|
+
modelId,
|
|
147
|
+
sourceIds,
|
|
148
|
+
})),
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function getSearchIndex() {
|
|
154
|
+
if (_searchIndex)
|
|
155
|
+
return _searchIndex;
|
|
156
|
+
const makesById = new Map();
|
|
157
|
+
const makeRowsById = new Map();
|
|
158
|
+
for (const make of data_1.default.makes) {
|
|
159
|
+
const baseForm = normalizeSearchText(make.make_name);
|
|
160
|
+
const aliases = MAKE_SEARCH_ALIASES.get(baseForm.spaced) ?? [];
|
|
161
|
+
makesById.set(make.make_id, {
|
|
162
|
+
makeId: make.make_id,
|
|
163
|
+
makeName: make.make_name,
|
|
164
|
+
forms: uniqueForms([baseForm, ...aliases.map(normalizeSearchText)]),
|
|
165
|
+
rows: [],
|
|
166
|
+
});
|
|
167
|
+
makeRowsById.set(make.make_id, new Map());
|
|
168
|
+
}
|
|
169
|
+
const modelsByKey = new Map();
|
|
170
|
+
for (const row of data_1.default.models) {
|
|
171
|
+
const [year, makeId, modelId, modelNameIndex, vehicleTypeId, sourceMask] = row;
|
|
172
|
+
const make = makesById.get(makeId);
|
|
173
|
+
const modelName = data_1.default.modelNames[modelNameIndex];
|
|
174
|
+
const vehicleTypeName = vehicleTypeNameById.get(vehicleTypeId);
|
|
175
|
+
if (!make || modelName == null || vehicleTypeName == null)
|
|
176
|
+
continue;
|
|
177
|
+
const makeRows = makeRowsById.get(makeId);
|
|
178
|
+
const makeRowKey = `${year}\u0000${vehicleTypeId}`;
|
|
179
|
+
const existingMakeRow = makeRows.get(makeRowKey);
|
|
180
|
+
if (existingMakeRow)
|
|
181
|
+
existingMakeRow.sourceMask |= sourceMask;
|
|
182
|
+
else
|
|
183
|
+
makeRows.set(makeRowKey, { year, vehicleTypeId, sourceMask });
|
|
184
|
+
const modelForm = normalizeSearchText(modelName);
|
|
185
|
+
const key = `${makeId}\u0000${modelForm.compact}\u0000${vehicleTypeId}`;
|
|
186
|
+
let model = modelsByKey.get(key);
|
|
187
|
+
if (!model) {
|
|
188
|
+
model = {
|
|
189
|
+
makeId,
|
|
190
|
+
makeName: make.makeName,
|
|
191
|
+
vehicleTypeId,
|
|
192
|
+
vehicleTypeName,
|
|
193
|
+
makeForms: make.forms,
|
|
194
|
+
modelForms: [],
|
|
195
|
+
combinedForms: [],
|
|
196
|
+
modelFormMap: new Map(),
|
|
197
|
+
rows: [],
|
|
198
|
+
};
|
|
199
|
+
modelsByKey.set(key, model);
|
|
200
|
+
}
|
|
201
|
+
model.modelFormMap.set(`${modelForm.spaced}\u0000${modelForm.compact}`, modelForm);
|
|
202
|
+
model.rows.push(row);
|
|
203
|
+
}
|
|
204
|
+
for (const make of makesById.values()) {
|
|
205
|
+
make.rows = [...makeRowsById.get(make.makeId).values()];
|
|
206
|
+
}
|
|
207
|
+
const models = [...modelsByKey.values()].map(({ modelFormMap, ...model }) => {
|
|
208
|
+
model.modelForms = [...modelFormMap.values()];
|
|
209
|
+
model.combinedForms = uniqueForms(model.makeForms.flatMap((makeForm) => model.modelForms.map((modelForm) => normalizeSearchText(`${makeForm.spaced} ${modelForm.spaced}`))));
|
|
210
|
+
return model;
|
|
211
|
+
});
|
|
212
|
+
_searchIndex = {
|
|
213
|
+
makes: [...makesById.values()],
|
|
214
|
+
models,
|
|
215
|
+
};
|
|
216
|
+
return _searchIndex;
|
|
217
|
+
}
|
|
218
|
+
function normalizeSearchText(value) {
|
|
219
|
+
const spaced = value
|
|
220
|
+
.normalize("NFKD")
|
|
221
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
222
|
+
.toLocaleLowerCase("en-US")
|
|
223
|
+
.replace(/&/g, " and ")
|
|
224
|
+
.replace(/[\u2019']/g, "")
|
|
225
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
226
|
+
.trim()
|
|
227
|
+
.replace(/\s+/g, " ");
|
|
228
|
+
return {
|
|
229
|
+
spaced,
|
|
230
|
+
compact: spaced.replace(/\s/g, ""),
|
|
231
|
+
tokens: spaced.length === 0 ? [] : spaced.split(" "),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function uniqueForms(forms) {
|
|
235
|
+
const seen = new Set();
|
|
236
|
+
return forms.filter((form) => {
|
|
237
|
+
const key = `${form.spaced}\u0000${form.compact}`;
|
|
238
|
+
if (seen.has(key))
|
|
239
|
+
return false;
|
|
240
|
+
seen.add(key);
|
|
241
|
+
return true;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
function parseQueries(query, optionYear) {
|
|
245
|
+
const fullText = normalizeSearchText(query);
|
|
246
|
+
if (fullText.spaced.length === 0)
|
|
247
|
+
return [];
|
|
248
|
+
const detectedYears = [...query.matchAll(/\b(?:19|20)\d{2}\b/g)].map((match) => Number(match[0]));
|
|
249
|
+
const uniqueYears = [...new Set(detectedYears)];
|
|
250
|
+
const onlyYearTokens = fullText.tokens.length > 0 && fullText.tokens.every((token) => {
|
|
251
|
+
const value = Number(token);
|
|
252
|
+
return Number.isInteger(value) && uniqueYears.includes(value);
|
|
253
|
+
});
|
|
254
|
+
if (onlyYearTokens)
|
|
255
|
+
return [];
|
|
256
|
+
const interpretations = [
|
|
257
|
+
{ text: fullText, year: optionYear, interpretationRank: 0 },
|
|
258
|
+
];
|
|
259
|
+
const inferredYears = optionYear == null ? uniqueYears : uniqueYears.filter((year) => year === optionYear);
|
|
260
|
+
for (const year of inferredYears) {
|
|
261
|
+
const text = normalizeSearchText(query.replace(new RegExp(`\\b${year}\\b`, "g"), " "));
|
|
262
|
+
if (text.spaced.length === 0)
|
|
263
|
+
continue;
|
|
264
|
+
interpretations.push({ text, year: optionYear ?? year, interpretationRank: 1 });
|
|
265
|
+
}
|
|
266
|
+
return interpretations;
|
|
267
|
+
}
|
|
268
|
+
function normalizeLimit(limit) {
|
|
269
|
+
if (limit == null)
|
|
270
|
+
return 10;
|
|
271
|
+
if (!Number.isFinite(limit) || limit <= 0)
|
|
272
|
+
return 0;
|
|
273
|
+
return Math.min(100, Math.floor(limit));
|
|
274
|
+
}
|
|
275
|
+
function matchForms(query, forms, fuzzyOnly) {
|
|
276
|
+
let best;
|
|
277
|
+
for (const form of forms) {
|
|
278
|
+
const match = fuzzyOnly ? fuzzyMatch(query, form) : nonFuzzyMatch(query, form);
|
|
279
|
+
if (match && (!best || compareMatches(match, best) < 0))
|
|
280
|
+
best = match;
|
|
281
|
+
}
|
|
282
|
+
return best;
|
|
283
|
+
}
|
|
284
|
+
function nonFuzzyMatch(query, target) {
|
|
285
|
+
if (query.spaced === target.spaced || query.compact === target.compact) {
|
|
286
|
+
return lexicalMatch("exact", 0);
|
|
287
|
+
}
|
|
288
|
+
if (target.spaced.startsWith(query.spaced) || target.compact.startsWith(query.compact)) {
|
|
289
|
+
return lexicalMatch("prefix", Math.max(0, target.compact.length - query.compact.length));
|
|
290
|
+
}
|
|
291
|
+
const tokenCloseness = tokenPrefixCloseness(query.tokens, target.tokens);
|
|
292
|
+
if (tokenCloseness != null)
|
|
293
|
+
return lexicalMatch("token", tokenCloseness);
|
|
294
|
+
const spacedIndex = target.spaced.indexOf(query.spaced);
|
|
295
|
+
const compactIndex = target.compact.indexOf(query.compact);
|
|
296
|
+
if (spacedIndex >= 0 || compactIndex >= 0) {
|
|
297
|
+
const index = Math.min(spacedIndex < 0 ? Number.MAX_SAFE_INTEGER : spacedIndex, compactIndex < 0 ? Number.MAX_SAFE_INTEGER : compactIndex);
|
|
298
|
+
return lexicalMatch("contains", index + target.compact.length - query.compact.length);
|
|
299
|
+
}
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
function fuzzyMatch(query, target) {
|
|
303
|
+
const fullLimit = allowedEdits(query.compact.length);
|
|
304
|
+
if (fullLimit > 0 && Math.abs(query.compact.length - target.compact.length) <= fullLimit) {
|
|
305
|
+
const distance = damerauLevenshtein(query.compact, target.compact, fullLimit);
|
|
306
|
+
if (distance <= fullLimit)
|
|
307
|
+
return lexicalMatch("fuzzy", distance);
|
|
308
|
+
}
|
|
309
|
+
const tokenDistance = fuzzyTokenDistance(query.tokens, target.tokens);
|
|
310
|
+
return tokenDistance == null ? undefined : lexicalMatch("fuzzy", tokenDistance);
|
|
311
|
+
}
|
|
312
|
+
function lexicalMatch(kind, closeness) {
|
|
313
|
+
return { kind, rank: MATCH_RANK[kind], closeness };
|
|
314
|
+
}
|
|
315
|
+
function compareMatches(left, right) {
|
|
316
|
+
return left.rank - right.rank || left.closeness - right.closeness;
|
|
317
|
+
}
|
|
318
|
+
function tokenPrefixCloseness(queryTokens, targetTokens) {
|
|
319
|
+
if (queryTokens.length === 0 || queryTokens.length > targetTokens.length)
|
|
320
|
+
return undefined;
|
|
321
|
+
const unused = new Set(targetTokens.map((_, index) => index));
|
|
322
|
+
let closeness = 0;
|
|
323
|
+
for (const queryToken of [...queryTokens].sort((left, right) => right.length - left.length)) {
|
|
324
|
+
let bestIndex;
|
|
325
|
+
let bestDifference = Number.MAX_SAFE_INTEGER;
|
|
326
|
+
for (const index of unused) {
|
|
327
|
+
const targetToken = targetTokens[index];
|
|
328
|
+
if (!targetToken.startsWith(queryToken))
|
|
329
|
+
continue;
|
|
330
|
+
const difference = targetToken.length - queryToken.length;
|
|
331
|
+
if (difference < bestDifference) {
|
|
332
|
+
bestDifference = difference;
|
|
333
|
+
bestIndex = index;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (bestIndex == null)
|
|
337
|
+
return undefined;
|
|
338
|
+
unused.delete(bestIndex);
|
|
339
|
+
closeness += bestDifference;
|
|
340
|
+
}
|
|
341
|
+
return closeness;
|
|
342
|
+
}
|
|
343
|
+
function fuzzyTokenDistance(queryTokens, targetTokens) {
|
|
344
|
+
if (queryTokens.length === 0 || queryTokens.length > targetTokens.length)
|
|
345
|
+
return undefined;
|
|
346
|
+
const unused = new Set(targetTokens.map((_, index) => index));
|
|
347
|
+
let totalDistance = 0;
|
|
348
|
+
for (const queryToken of [...queryTokens].sort((left, right) => right.length - left.length)) {
|
|
349
|
+
const limit = allowedEdits(queryToken.length);
|
|
350
|
+
if (limit === 0)
|
|
351
|
+
return undefined;
|
|
352
|
+
let bestIndex;
|
|
353
|
+
let bestDistance = Number.MAX_SAFE_INTEGER;
|
|
354
|
+
for (const index of unused) {
|
|
355
|
+
const targetToken = targetTokens[index];
|
|
356
|
+
if (Math.abs(queryToken.length - targetToken.length) > limit)
|
|
357
|
+
continue;
|
|
358
|
+
const distance = damerauLevenshtein(queryToken, targetToken, limit);
|
|
359
|
+
if (distance < bestDistance) {
|
|
360
|
+
bestDistance = distance;
|
|
361
|
+
bestIndex = index;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (bestIndex == null || bestDistance > limit)
|
|
365
|
+
return undefined;
|
|
366
|
+
unused.delete(bestIndex);
|
|
367
|
+
totalDistance += bestDistance;
|
|
368
|
+
}
|
|
369
|
+
return totalDistance;
|
|
370
|
+
}
|
|
371
|
+
function allowedEdits(length) {
|
|
372
|
+
if (length < 4)
|
|
373
|
+
return 0;
|
|
374
|
+
return length < 6 ? 1 : 2;
|
|
375
|
+
}
|
|
376
|
+
function damerauLevenshtein(left, right, maximum) {
|
|
377
|
+
if (Math.abs(left.length - right.length) > maximum)
|
|
378
|
+
return maximum + 1;
|
|
379
|
+
let previousPrevious = new Array(right.length + 1).fill(0);
|
|
380
|
+
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
381
|
+
for (let leftIndex = 1; leftIndex <= left.length; leftIndex++) {
|
|
382
|
+
const current = new Array(right.length + 1).fill(0);
|
|
383
|
+
current[0] = leftIndex;
|
|
384
|
+
for (let rightIndex = 1; rightIndex <= right.length; rightIndex++) {
|
|
385
|
+
const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
|
|
386
|
+
current[rightIndex] = Math.min(previous[rightIndex] + 1, current[rightIndex - 1] + 1, previous[rightIndex - 1] + substitutionCost);
|
|
387
|
+
if (leftIndex > 1 &&
|
|
388
|
+
rightIndex > 1 &&
|
|
389
|
+
left[leftIndex - 1] === right[rightIndex - 2] &&
|
|
390
|
+
left[leftIndex - 2] === right[rightIndex - 1]) {
|
|
391
|
+
current[rightIndex] = Math.min(current[rightIndex], previousPrevious[rightIndex - 2] + 1);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
previousPrevious = previous;
|
|
395
|
+
previous = current;
|
|
396
|
+
}
|
|
397
|
+
return previous[right.length];
|
|
398
|
+
}
|
|
399
|
+
function selectMakeAvailability(rows, year, vehicleTypeId, sourceMask) {
|
|
400
|
+
const years = new Set();
|
|
401
|
+
let combinedSourceMask = 0;
|
|
402
|
+
for (const row of rows) {
|
|
403
|
+
if (year != null && row.year !== year)
|
|
404
|
+
continue;
|
|
405
|
+
if (vehicleTypeId != null && row.vehicleTypeId !== vehicleTypeId)
|
|
406
|
+
continue;
|
|
407
|
+
if (sourceMask != null && (row.sourceMask & sourceMask) === 0)
|
|
408
|
+
continue;
|
|
409
|
+
years.add(row.year);
|
|
410
|
+
combinedSourceMask |= row.sourceMask;
|
|
411
|
+
}
|
|
412
|
+
return { years: [...years].sort((left, right) => left - right), sourceMask: combinedSourceMask };
|
|
413
|
+
}
|
|
414
|
+
function selectModelVariants(rows, year, sourceMask) {
|
|
415
|
+
const variantsByYear = new Map();
|
|
416
|
+
for (const row of rows) {
|
|
417
|
+
const [rowYear, , modelId, modelNameIndex, , rowSourceMask] = row;
|
|
418
|
+
if (year != null && rowYear !== year)
|
|
419
|
+
continue;
|
|
420
|
+
if (sourceMask != null && (rowSourceMask & sourceMask) === 0)
|
|
421
|
+
continue;
|
|
422
|
+
const existing = variantsByYear.get(rowYear);
|
|
423
|
+
const candidate = {
|
|
424
|
+
year: rowYear,
|
|
425
|
+
modelId,
|
|
426
|
+
modelName: data_1.default.modelNames[modelNameIndex],
|
|
427
|
+
sourceMask: rowSourceMask,
|
|
428
|
+
sourceIds: getSourceIds(rowSourceMask),
|
|
429
|
+
};
|
|
430
|
+
if (!existing) {
|
|
431
|
+
variantsByYear.set(rowYear, candidate);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const combinedSourceMask = existing.sourceMask | rowSourceMask;
|
|
435
|
+
const preferred = compareRepresentativeVariants(candidate, existing) < 0 ? candidate : existing;
|
|
436
|
+
variantsByYear.set(rowYear, {
|
|
437
|
+
...preferred,
|
|
438
|
+
sourceMask: combinedSourceMask,
|
|
439
|
+
sourceIds: getSourceIds(combinedSourceMask),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
return [...variantsByYear.values()].sort((left, right) => left.year - right.year);
|
|
443
|
+
}
|
|
444
|
+
function compareRepresentativeVariants(left, right) {
|
|
445
|
+
return (firstSourceIndex(left.sourceMask) - firstSourceIndex(right.sourceMask) ||
|
|
446
|
+
displayNameRank(left.modelName) - displayNameRank(right.modelName) ||
|
|
447
|
+
left.modelName.length - right.modelName.length ||
|
|
448
|
+
compareStrings(left.modelName, right.modelName) ||
|
|
449
|
+
left.modelId - right.modelId);
|
|
450
|
+
}
|
|
451
|
+
function firstSourceIndex(sourceMask) {
|
|
452
|
+
for (let index = 0; index < data_1.default.sources.length; index++) {
|
|
453
|
+
if ((sourceMask & 2 ** index) !== 0)
|
|
454
|
+
return index;
|
|
455
|
+
}
|
|
456
|
+
return Number.MAX_SAFE_INTEGER;
|
|
457
|
+
}
|
|
458
|
+
function displayNameRank(value) {
|
|
459
|
+
return /[a-z]/.test(value) && /[A-Z]/.test(value) ? 0 : 1;
|
|
460
|
+
}
|
|
461
|
+
function getSourceIds(sourceMask) {
|
|
462
|
+
const sourceIds = [];
|
|
463
|
+
for (let index = 0; index < data_1.default.sources.length; index++) {
|
|
464
|
+
if ((sourceMask & 2 ** index) !== 0)
|
|
465
|
+
sourceIds.push(data_1.default.sources[index].source_id);
|
|
466
|
+
}
|
|
467
|
+
return sourceIds;
|
|
468
|
+
}
|
|
469
|
+
function compareRankedResults(left, right) {
|
|
470
|
+
return (left.rank - right.rank ||
|
|
471
|
+
left.interpretationRank - right.interpretationRank ||
|
|
472
|
+
left.closeness - right.closeness ||
|
|
473
|
+
(left.result.kind === right.result.kind ? 0 : left.result.kind === "make" ? -1 : 1) ||
|
|
474
|
+
compareStrings(left.result.makeName, right.result.makeName) ||
|
|
475
|
+
compareStrings(left.result.kind === "model" ? left.result.modelName : "", right.result.kind === "model" ? right.result.modelName : "") ||
|
|
476
|
+
(left.result.kind === "model" ? left.result.vehicleTypeId : 0) -
|
|
477
|
+
(right.result.kind === "model" ? right.result.vehicleTypeId : 0) ||
|
|
478
|
+
compareStrings(left.key, right.key));
|
|
479
|
+
}
|
|
480
|
+
function compareStrings(left, right) {
|
|
481
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
482
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meterapp/vehicle-db",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Offline international
|
|
3
|
+
"version": "2.6.0",
|
|
4
|
+
"description": "Offline international database for cars, motorcycles, trucks, buses, MPVs, and more",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/MeterApp/vehicle-db.git"
|
|
8
8
|
},
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"types": "dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"require": "./dist/index.js",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./search": {
|
|
18
|
+
"types": "./dist/search.d.ts",
|
|
19
|
+
"require": "./dist/search.js",
|
|
20
|
+
"default": "./dist/search.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"typesVersions": {
|
|
25
|
+
"*": {
|
|
26
|
+
"search": [
|
|
27
|
+
"dist/search.d.ts"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"sideEffects": false,
|
|
11
32
|
"files": [
|
|
12
33
|
"dist"
|
|
13
34
|
],
|
|
@@ -28,6 +49,9 @@
|
|
|
28
49
|
"nhtsa",
|
|
29
50
|
"dvla",
|
|
30
51
|
"vehicles",
|
|
52
|
+
"vehicle-search",
|
|
53
|
+
"autocomplete",
|
|
54
|
+
"fuzzy-search",
|
|
31
55
|
"makes",
|
|
32
56
|
"models",
|
|
33
57
|
"motorcycles",
|