@zukall/zap 0.2.2 → 0.3.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/PROTOCOL.md +1 -1
- package/README.md +243 -14
- package/asset/zukall-haiti-country-grid.webp +0 -0
- package/dist/cjs/distance.d.ts +8 -0
- package/dist/cjs/distance.js +61 -0
- package/dist/cjs/index.d.ts +11 -2
- package/dist/cjs/index.js +9 -1
- package/dist/cjs/locations.d.ts +11 -0
- package/dist/cjs/locations.js +67 -0
- package/dist/cjs/route.d.ts +10 -0
- package/dist/cjs/route.js +289 -0
- package/dist/cjs/types.d.ts +70 -0
- package/dist/esm/distance.d.ts +8 -0
- package/dist/esm/distance.js +55 -0
- package/dist/esm/index.d.ts +11 -2
- package/dist/esm/index.js +6 -1
- package/dist/esm/locations.d.ts +11 -0
- package/dist/esm/locations.js +61 -0
- package/dist/esm/route.d.ts +10 -0
- package/dist/esm/route.js +285 -0
- package/dist/esm/types.d.ts +70 -0
- package/package.json +2 -2
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ROUTE_OPTIMIZATION = void 0;
|
|
4
|
+
exports.optimizeRoute = optimizeRoute;
|
|
5
|
+
const distance_js_1 = require("./distance.js");
|
|
6
|
+
const locations_js_1 = require("./locations.js");
|
|
7
|
+
/** Work limits are separate from address protocol/configuration versions. */
|
|
8
|
+
exports.ROUTE_OPTIMIZATION = Object.freeze({
|
|
9
|
+
exactMaxStops: 12, maxStops: 1000, twoOptMaxPasses: 100,
|
|
10
|
+
});
|
|
11
|
+
const at = (matrix, from, to) => matrix[from][to];
|
|
12
|
+
function validateEndpoint(index, count, name) {
|
|
13
|
+
if (index !== undefined && (!Number.isSafeInteger(index) || index < 0 || index >= count)) {
|
|
14
|
+
throw new RangeError(`${name} must be an original input index within points.`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function validateRouteOptions(points, options) {
|
|
18
|
+
(0, locations_js_1.validateLocationOptions)(options);
|
|
19
|
+
if (!Array.isArray(points))
|
|
20
|
+
throw new TypeError("points must be an array of locations.");
|
|
21
|
+
if (points.length > exports.ROUTE_OPTIMIZATION.maxStops) {
|
|
22
|
+
throw new RangeError(`At most ${exports.ROUTE_OPTIMIZATION.maxStops} stops are supported per itinerary.`);
|
|
23
|
+
}
|
|
24
|
+
for (let index = 0; index < points.length; index++) {
|
|
25
|
+
if (!(index in points))
|
|
26
|
+
throw new TypeError("points must not contain empty slots.");
|
|
27
|
+
}
|
|
28
|
+
validateEndpoint(options.startIndex, points.length, "startIndex");
|
|
29
|
+
validateEndpoint(options.endIndex, points.length, "endIndex");
|
|
30
|
+
if (options.returnToStart !== undefined && typeof options.returnToStart !== "boolean") {
|
|
31
|
+
throw new TypeError("returnToStart must be a boolean.");
|
|
32
|
+
}
|
|
33
|
+
if (options.returnToStart && options.endIndex !== undefined) {
|
|
34
|
+
throw new RangeError("endIndex cannot be combined with returnToStart.");
|
|
35
|
+
}
|
|
36
|
+
if (points.length > 1 && options.endIndex === (options.startIndex ?? 0)) {
|
|
37
|
+
throw new RangeError("An open itinerary must have distinct startIndex/endIndex; use returnToStart for a round trip.");
|
|
38
|
+
}
|
|
39
|
+
(0, distance_js_1.getDistanceMethod)(options.method);
|
|
40
|
+
if (options.distanceMatrix !== undefined && options.method !== undefined) {
|
|
41
|
+
throw new TypeError("distanceMatrix cannot be combined with method.");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function validateMatrix(matrix, count) {
|
|
45
|
+
if (!Array.isArray(matrix) || matrix.length !== count) {
|
|
46
|
+
throw new RangeError("distanceMatrix must have one row and column per original input stop.");
|
|
47
|
+
}
|
|
48
|
+
// Reserve headroom so summing every leg is finite, including a closing leg.
|
|
49
|
+
const maximum = Number.MAX_VALUE / (count + 1);
|
|
50
|
+
return Array.from({ length: count }, (_, rowIndex) => {
|
|
51
|
+
const row = matrix[rowIndex];
|
|
52
|
+
if (!Array.isArray(row) || row.length !== count) {
|
|
53
|
+
throw new RangeError("distanceMatrix must have one row and column per original input stop.");
|
|
54
|
+
}
|
|
55
|
+
return Array.from({ length: count }, (_, columnIndex) => {
|
|
56
|
+
const value = row[columnIndex];
|
|
57
|
+
if (!Number.isFinite(value) || value < 0 || value > maximum || (rowIndex === columnIndex && value !== 0)) {
|
|
58
|
+
throw new RangeError("distanceMatrix entries must be finite nonnegative meters with a zero diagonal and finite route totals.");
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function buildMatrix(points, options) {
|
|
65
|
+
const coordinates = points.map(locations_js_1.getLocationCoordinates);
|
|
66
|
+
if (options.distanceMatrix !== undefined) {
|
|
67
|
+
return { matrix: validateMatrix(options.distanceMatrix, points.length), method: "distance-matrix" };
|
|
68
|
+
}
|
|
69
|
+
const method = (0, distance_js_1.getDistanceMethod)(options.method);
|
|
70
|
+
const projected = method === "projected" ? points.map(locations_js_1.getProjectedPoint) : undefined;
|
|
71
|
+
const gridId = projected?.[0]?.gridId;
|
|
72
|
+
if (projected?.some(point => point.gridId !== gridId)) {
|
|
73
|
+
throw new RangeError("Projected distance requires exactly the same gridId for every stop.");
|
|
74
|
+
}
|
|
75
|
+
const matrix = Array.from({ length: points.length }, () => Array(points.length).fill(0));
|
|
76
|
+
for (let left = 0; left < points.length; left++) {
|
|
77
|
+
for (let right = left + 1; right < points.length; right++) {
|
|
78
|
+
const meters = (0, distance_js_1.requireFiniteDistance)(projected ? Math.hypot(projected[right].northingMeters - projected[left].northingMeters, projected[right].eastingMeters - projected[left].eastingMeters) : (0, distance_js_1.haversineMeters)(coordinates[left], coordinates[right]));
|
|
79
|
+
if (meters > Number.MAX_VALUE / (points.length + 1))
|
|
80
|
+
throw new RangeError("Route distances must have finite totals.");
|
|
81
|
+
matrix[left][right] = meters;
|
|
82
|
+
matrix[right][left] = meters;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { matrix, method, ...(gridId !== undefined ? { gridId } : {}) };
|
|
86
|
+
}
|
|
87
|
+
function routeCost(order, matrix, closed) {
|
|
88
|
+
let total = 0;
|
|
89
|
+
for (let index = 1; index < order.length; index++)
|
|
90
|
+
total += at(matrix, order[index - 1], order[index]);
|
|
91
|
+
if (closed && order.length > 1)
|
|
92
|
+
total += at(matrix, order[order.length - 1], order[0]);
|
|
93
|
+
return (0, distance_js_1.requireFiniteDistance)(total);
|
|
94
|
+
}
|
|
95
|
+
function feasibleOrder(count, start, end) {
|
|
96
|
+
if (count === 0)
|
|
97
|
+
return [];
|
|
98
|
+
const order = [start];
|
|
99
|
+
for (let index = 0; index < count; index++)
|
|
100
|
+
if (index !== start && index !== end)
|
|
101
|
+
order.push(index);
|
|
102
|
+
if (end !== undefined && end !== start)
|
|
103
|
+
order.push(end);
|
|
104
|
+
return order;
|
|
105
|
+
}
|
|
106
|
+
/** Held–Karp subset dynamic programming, including directed matrices and endpoint constraints. */
|
|
107
|
+
function exactOrder(matrix, start, end, closed) {
|
|
108
|
+
const count = matrix.length;
|
|
109
|
+
const free = Array.from({ length: count }, (_, index) => index).filter(index => index !== start && index !== end);
|
|
110
|
+
const size = free.length;
|
|
111
|
+
if (size === 0)
|
|
112
|
+
return feasibleOrder(count, start, end);
|
|
113
|
+
const states = 1 << size;
|
|
114
|
+
const costs = new Float64Array(states * size).fill(Infinity);
|
|
115
|
+
const keys = new Float64Array(states * size).fill(Infinity);
|
|
116
|
+
const parents = new Int16Array(states * size).fill(-1);
|
|
117
|
+
// Up to 12 base-13 digits fit exactly in a JS integer; keys settle equal-cost ties by input index.
|
|
118
|
+
const base = count + 1;
|
|
119
|
+
for (let last = 0; last < size; last++) {
|
|
120
|
+
const state = (1 << last) * size + last;
|
|
121
|
+
costs[state] = at(matrix, start, free[last]);
|
|
122
|
+
keys[state] = (start + 1) * base + free[last] + 1;
|
|
123
|
+
}
|
|
124
|
+
for (let mask = 1; mask < states; mask++) {
|
|
125
|
+
for (let last = 0; last < size; last++) {
|
|
126
|
+
if (!(mask & (1 << last)))
|
|
127
|
+
continue;
|
|
128
|
+
const state = mask * size + last;
|
|
129
|
+
if (!Number.isFinite(costs[state]))
|
|
130
|
+
continue;
|
|
131
|
+
for (let next = 0; next < size; next++) {
|
|
132
|
+
if (mask & (1 << next))
|
|
133
|
+
continue;
|
|
134
|
+
const nextState = (mask | (1 << next)) * size + next;
|
|
135
|
+
const candidate = costs[state] + at(matrix, free[last], free[next]);
|
|
136
|
+
const key = keys[state] * base + free[next] + 1;
|
|
137
|
+
if (candidate < costs[nextState] || (candidate === costs[nextState] && key < keys[nextState])) {
|
|
138
|
+
costs[nextState] = candidate;
|
|
139
|
+
keys[nextState] = key;
|
|
140
|
+
parents[nextState] = last;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const full = states - 1;
|
|
146
|
+
let bestCost = Infinity;
|
|
147
|
+
let bestKey = Infinity;
|
|
148
|
+
let last = -1;
|
|
149
|
+
for (let candidate = 0; candidate < size; candidate++) {
|
|
150
|
+
const state = full * size + candidate;
|
|
151
|
+
const destination = end ?? (closed ? start : undefined);
|
|
152
|
+
const cost = costs[state] + (destination === undefined ? 0 : at(matrix, free[candidate], destination));
|
|
153
|
+
if (cost < bestCost || (cost === bestCost && keys[state] < bestKey)) {
|
|
154
|
+
bestCost = cost;
|
|
155
|
+
bestKey = keys[state];
|
|
156
|
+
last = candidate;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const reversed = [];
|
|
160
|
+
let mask = full;
|
|
161
|
+
while (last >= 0) {
|
|
162
|
+
reversed.push(free[last]);
|
|
163
|
+
const previous = parents[mask * size + last];
|
|
164
|
+
mask ^= 1 << last;
|
|
165
|
+
last = previous;
|
|
166
|
+
}
|
|
167
|
+
return [start, ...reversed.reverse(), ...(end === undefined ? [] : [end])];
|
|
168
|
+
}
|
|
169
|
+
function nearestNeighbor(matrix, start, end) {
|
|
170
|
+
const remaining = new Set(Array.from({ length: matrix.length }, (_, index) => index).filter(index => index !== start && index !== end));
|
|
171
|
+
const order = [start];
|
|
172
|
+
while (remaining.size) {
|
|
173
|
+
const current = order[order.length - 1];
|
|
174
|
+
let next = -1;
|
|
175
|
+
let best = Infinity;
|
|
176
|
+
for (const candidate of remaining) {
|
|
177
|
+
const cost = at(matrix, current, candidate);
|
|
178
|
+
if (cost < best || (cost === best && candidate < next)) {
|
|
179
|
+
best = cost;
|
|
180
|
+
next = candidate;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
order.push(next);
|
|
184
|
+
remaining.delete(next);
|
|
185
|
+
}
|
|
186
|
+
if (end !== undefined)
|
|
187
|
+
order.push(end);
|
|
188
|
+
return order;
|
|
189
|
+
}
|
|
190
|
+
/** Fixed-start 2-opt; directed reversal costs are included, not just the two boundary edges. */
|
|
191
|
+
function twoOpt(seed, matrix, closed, fixedEnd) {
|
|
192
|
+
let order = [...seed];
|
|
193
|
+
let total = routeCost(order, matrix, closed);
|
|
194
|
+
for (let pass = 0; pass < exports.ROUTE_OPTIMIZATION.twoOptMaxPasses; pass++) {
|
|
195
|
+
const reverseCosts = Array(order.length).fill(0);
|
|
196
|
+
for (let index = 0; index < order.length - 1; index++) {
|
|
197
|
+
reverseCosts[index + 1] = reverseCosts[index] + at(matrix, order[index + 1], order[index]) - at(matrix, order[index], order[index + 1]);
|
|
198
|
+
}
|
|
199
|
+
let improved = false;
|
|
200
|
+
const maximumEnd = order.length - (fixedEnd ? 2 : 1);
|
|
201
|
+
for (let first = 1; first < maximumEnd && !improved; first++) {
|
|
202
|
+
for (let last = first + 1; last <= maximumEnd; last++) {
|
|
203
|
+
const before = order[first - 1];
|
|
204
|
+
const head = order[first];
|
|
205
|
+
const tail = order[last];
|
|
206
|
+
const after = order[last + 1] ?? (closed ? order[0] : undefined);
|
|
207
|
+
const delta = at(matrix, before, tail) - at(matrix, before, head)
|
|
208
|
+
+ (after === undefined ? 0 : at(matrix, head, after) - at(matrix, tail, after))
|
|
209
|
+
+ reverseCosts[last] - reverseCosts[first];
|
|
210
|
+
if (delta >= 0)
|
|
211
|
+
continue;
|
|
212
|
+
const candidate = [...order.slice(0, first), ...order.slice(first, last + 1).reverse(), ...order.slice(last + 1)];
|
|
213
|
+
const candidateCost = routeCost(candidate, matrix, closed);
|
|
214
|
+
if (candidateCost < total) {
|
|
215
|
+
order = candidate;
|
|
216
|
+
total = candidateCost;
|
|
217
|
+
improved = true;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (!improved)
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
return order;
|
|
226
|
+
}
|
|
227
|
+
function lexicographicallyBefore(left, right) {
|
|
228
|
+
for (let index = 0; index < left.length; index++) {
|
|
229
|
+
if (left[index] !== right[index])
|
|
230
|
+
return left[index] < right[index];
|
|
231
|
+
}
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
function optimizeResolved(inputs, points, options) {
|
|
235
|
+
const { matrix, method, gridId } = buildMatrix(points, options);
|
|
236
|
+
const start = options.startIndex ?? 0;
|
|
237
|
+
const end = options.endIndex;
|
|
238
|
+
const closed = options.returnToStart ?? false;
|
|
239
|
+
const baseline = feasibleOrder(points.length, start, end);
|
|
240
|
+
const originalTotalMeters = routeCost(baseline, matrix, closed);
|
|
241
|
+
const exact = points.length <= exports.ROUTE_OPTIMIZATION.exactMaxStops;
|
|
242
|
+
let order = baseline;
|
|
243
|
+
if (points.length > 1) {
|
|
244
|
+
if (exact)
|
|
245
|
+
order = exactOrder(matrix, start, end, closed);
|
|
246
|
+
else {
|
|
247
|
+
// Optimizing both seeds and retaining the baseline guarantees no regression against supplied order.
|
|
248
|
+
let bestCost = originalTotalMeters;
|
|
249
|
+
for (const seed of [baseline, nearestNeighbor(matrix, start, end)]) {
|
|
250
|
+
const candidate = twoOpt(seed, matrix, closed, end !== undefined);
|
|
251
|
+
const cost = routeCost(candidate, matrix, closed);
|
|
252
|
+
if (cost < bestCost || (cost === bestCost && lexicographicallyBefore(candidate, order))) {
|
|
253
|
+
order = candidate;
|
|
254
|
+
bestCost = cost;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const legs = [];
|
|
260
|
+
const addLeg = (fromIndex, toIndex, isReturn) => {
|
|
261
|
+
const meters = at(matrix, fromIndex, toIndex);
|
|
262
|
+
legs.push({ fromIndex, toIndex, meters, kilometers: meters / 1000, method, isReturn });
|
|
263
|
+
};
|
|
264
|
+
for (let index = 1; index < order.length; index++)
|
|
265
|
+
addLeg(order[index - 1], order[index], false);
|
|
266
|
+
if (closed && order.length > 1)
|
|
267
|
+
addLeg(order[order.length - 1], order[0], true);
|
|
268
|
+
const totalMeters = routeCost(order, matrix, closed);
|
|
269
|
+
return {
|
|
270
|
+
orderedStops: order.map(index => inputs[index]), originalIndices: order,
|
|
271
|
+
resolvedPoints: order.map(index => points[index]), legs, totalMeters, totalKilometers: totalMeters / 1000,
|
|
272
|
+
method, ...(gridId !== undefined ? { gridId } : {}), optimization: exact ? "exact" : "approximate",
|
|
273
|
+
algorithm: points.length < 2 ? "trivial" : exact ? "held-karp" : "nearest-neighbor-2-opt",
|
|
274
|
+
exactCutoff: exports.ROUTE_OPTIMIZATION.exactMaxStops, returnToStart: closed, originalTotalMeters,
|
|
275
|
+
description: method === "distance-matrix" ? "optimized distance-matrix itinerary" : "optimized straight-line itinerary",
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** Reorder stops without mutating them or changing any issued address/grid identity. */
|
|
279
|
+
function optimizeRoute(points, options = {}) {
|
|
280
|
+
validateRouteOptions(points, options);
|
|
281
|
+
// Snapshot array order before asynchronous lookups; retain each original stop reference.
|
|
282
|
+
const inputs = [...points];
|
|
283
|
+
const settings = { ...options };
|
|
284
|
+
if (settings.distanceMatrix !== undefined)
|
|
285
|
+
settings.distanceMatrix = validateMatrix(settings.distanceMatrix, inputs.length);
|
|
286
|
+
const resolved = (0, locations_js_1.resolveLocationPoints)(inputs, settings.resolveAddress);
|
|
287
|
+
return resolved instanceof Promise
|
|
288
|
+
? resolved.then(locations => optimizeResolved(inputs, locations, settings)) : optimizeResolved(inputs, resolved, settings);
|
|
289
|
+
}
|
package/dist/cjs/types.d.ts
CHANGED
|
@@ -112,3 +112,73 @@ export type ProvinceOption = Readonly<{
|
|
|
112
112
|
code: string;
|
|
113
113
|
name: string;
|
|
114
114
|
}>;
|
|
115
|
+
/** A saved location, including either address protocol's complete result. */
|
|
116
|
+
export type SavedLocationPoint = Readonly<{
|
|
117
|
+
coordinates: Coordinates;
|
|
118
|
+
}>;
|
|
119
|
+
export type LocationPoint = Coordinates | SavedLocationPoint;
|
|
120
|
+
export type LocationInput = LocationPoint | string;
|
|
121
|
+
/** Resolve an exact address string to its saved original GPS record. */
|
|
122
|
+
export type AddressResolver = (address: string, originalIndex: number) => SavedLocationPoint | PromiseLike<SavedLocationPoint>;
|
|
123
|
+
export type DistanceMethod = "haversine" | "projected";
|
|
124
|
+
export type RouteDistanceMethod = DistanceMethod | "distance-matrix";
|
|
125
|
+
export type DistanceOptions = {
|
|
126
|
+
/** Defaults to Haversine from the original GPS point. */
|
|
127
|
+
method?: DistanceMethod;
|
|
128
|
+
resolveAddress?: never;
|
|
129
|
+
};
|
|
130
|
+
/** Supplying a resolver makes the operation asynchronous, even for a cache lookup. */
|
|
131
|
+
export type ResolvedDistanceOptions = Omit<DistanceOptions, "resolveAddress"> & {
|
|
132
|
+
resolveAddress: AddressResolver;
|
|
133
|
+
};
|
|
134
|
+
export type DistanceResult = Readonly<{
|
|
135
|
+
meters: number;
|
|
136
|
+
kilometers: number;
|
|
137
|
+
}> & ({
|
|
138
|
+
readonly method: "haversine";
|
|
139
|
+
} | {
|
|
140
|
+
readonly method: "projected";
|
|
141
|
+
readonly gridId: string;
|
|
142
|
+
});
|
|
143
|
+
/** Meter costs indexed by original input index; directed/asymmetric matrices are supported. */
|
|
144
|
+
export type DistanceMatrix = readonly (readonly number[])[];
|
|
145
|
+
export type RouteOptions = DistanceOptions & {
|
|
146
|
+
/** Original input index of the fixed start; defaults to 0 for a nonempty list. */
|
|
147
|
+
startIndex?: number;
|
|
148
|
+
/** Original input index of the fixed end; incompatible with returnToStart. */
|
|
149
|
+
endIndex?: number;
|
|
150
|
+
returnToStart?: boolean;
|
|
151
|
+
/** Optional caller-supplied costs in meters; cannot be combined with method. */
|
|
152
|
+
distanceMatrix?: DistanceMatrix;
|
|
153
|
+
};
|
|
154
|
+
export type ResolvedRouteOptions = Omit<RouteOptions, "resolveAddress"> & {
|
|
155
|
+
resolveAddress: AddressResolver;
|
|
156
|
+
};
|
|
157
|
+
export type RouteLeg = Readonly<{
|
|
158
|
+
fromIndex: number;
|
|
159
|
+
toIndex: number;
|
|
160
|
+
meters: number;
|
|
161
|
+
kilometers: number;
|
|
162
|
+
method: RouteDistanceMethod;
|
|
163
|
+
/** The closing leg; the start is not duplicated in orderedStops. */
|
|
164
|
+
isReturn: boolean;
|
|
165
|
+
}>;
|
|
166
|
+
export type RouteResult<T extends LocationInput = LocationInput> = Readonly<{
|
|
167
|
+
/** Original inputs, retaining object identity and including coincident stops separately. */
|
|
168
|
+
orderedStops: readonly T[];
|
|
169
|
+
originalIndices: readonly number[];
|
|
170
|
+
/** Resolved GPS points/records in the same order as orderedStops. */
|
|
171
|
+
resolvedPoints: readonly LocationPoint[];
|
|
172
|
+
legs: readonly RouteLeg[];
|
|
173
|
+
totalMeters: number;
|
|
174
|
+
totalKilometers: number;
|
|
175
|
+
method: RouteDistanceMethod;
|
|
176
|
+
gridId?: string;
|
|
177
|
+
optimization: "exact" | "approximate";
|
|
178
|
+
algorithm: "trivial" | "held-karp" | "nearest-neighbor-2-opt";
|
|
179
|
+
exactCutoff: 12;
|
|
180
|
+
returnToStart: boolean;
|
|
181
|
+
/** Cost of supplied order with the requested start moved first and end moved last. */
|
|
182
|
+
originalTotalMeters: number;
|
|
183
|
+
description: "optimized straight-line itinerary" | "optimized distance-matrix itinerary";
|
|
184
|
+
}>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Coordinates, DistanceMethod, DistanceOptions, DistanceResult, LocationInput, LocationPoint, ResolvedDistanceOptions } from "./types.js";
|
|
2
|
+
export declare function getDistanceMethod(method: DistanceMethod | undefined): DistanceMethod;
|
|
3
|
+
/** Geographic great-circle surface distance, using the existing spherical Earth radius. */
|
|
4
|
+
export declare function haversineMeters(pointA: Coordinates, pointB: Coordinates): number;
|
|
5
|
+
export declare function requireFiniteDistance(meters: number): number;
|
|
6
|
+
export declare function getDistance(pointA: LocationPoint, pointB: LocationPoint, options?: DistanceOptions): DistanceResult;
|
|
7
|
+
export declare function getDistance(pointA: LocationInput, pointB: LocationInput, options: ResolvedDistanceOptions): Promise<DistanceResult>;
|
|
8
|
+
export declare function getDistance(pointA: LocationPoint, pointB: LocationPoint, options: DistanceOptions | ResolvedDistanceOptions): DistanceResult | Promise<DistanceResult>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ZAP_PROTOCOL } from "./protocol.js";
|
|
2
|
+
import { getLocationCoordinates, getProjectedPoint, resolveLocationPoints, validateLocationOptions } from "./locations.js";
|
|
3
|
+
export function getDistanceMethod(method) {
|
|
4
|
+
if (method !== undefined && method !== "haversine" && method !== "projected") {
|
|
5
|
+
throw new TypeError("method must be haversine or projected.");
|
|
6
|
+
}
|
|
7
|
+
return method ?? "haversine";
|
|
8
|
+
}
|
|
9
|
+
/** Geographic great-circle surface distance, using the existing spherical Earth radius. */
|
|
10
|
+
export function haversineMeters(pointA, pointB) {
|
|
11
|
+
const radians = (degrees) => degrees * Math.PI / 180;
|
|
12
|
+
const latitudeA = radians(pointA.latitude);
|
|
13
|
+
const latitudeB = radians(pointB.latitude);
|
|
14
|
+
let longitudeDifference = pointB.longitude - pointA.longitude;
|
|
15
|
+
if (longitudeDifference > 180)
|
|
16
|
+
longitudeDifference -= 360;
|
|
17
|
+
if (longitudeDifference < -180)
|
|
18
|
+
longitudeDifference += 360;
|
|
19
|
+
const cosineA = Math.abs(pointA.latitude) === 90 ? 0 : Math.cos(latitudeA);
|
|
20
|
+
const cosineB = Math.abs(pointB.latitude) === 90 ? 0 : Math.cos(latitudeB);
|
|
21
|
+
const haversine = Math.sin(radians(pointB.latitude - pointA.latitude) / 2) ** 2
|
|
22
|
+
+ cosineA * cosineB * Math.sin(radians(longitudeDifference) / 2) ** 2;
|
|
23
|
+
const clamped = Math.max(0, Math.min(1, haversine));
|
|
24
|
+
return 2 * ZAP_PROTOCOL.earthRadiusMeters * Math.atan2(Math.sqrt(clamped), Math.sqrt(1 - clamped));
|
|
25
|
+
}
|
|
26
|
+
export function requireFiniteDistance(meters) {
|
|
27
|
+
if (!Number.isFinite(meters) || meters < 0)
|
|
28
|
+
throw new RangeError("Distance must be finite and nonnegative.");
|
|
29
|
+
return meters === 0 ? 0 : meters;
|
|
30
|
+
}
|
|
31
|
+
function calculateDistance(points, method) {
|
|
32
|
+
const pointA = points[0];
|
|
33
|
+
const pointB = points[1];
|
|
34
|
+
const coordinatesA = getLocationCoordinates(pointA);
|
|
35
|
+
const coordinatesB = getLocationCoordinates(pointB);
|
|
36
|
+
if (method === "projected") {
|
|
37
|
+
const gridA = getProjectedPoint(pointA);
|
|
38
|
+
const gridB = getProjectedPoint(pointB);
|
|
39
|
+
if (gridA.gridId !== gridB.gridId) {
|
|
40
|
+
throw new RangeError("Projected distance requires exactly the same gridId.");
|
|
41
|
+
}
|
|
42
|
+
const meters = requireFiniteDistance(Math.hypot(gridB.northingMeters - gridA.northingMeters, gridB.eastingMeters - gridA.eastingMeters));
|
|
43
|
+
return { method, meters, kilometers: meters / 1000, gridId: gridA.gridId };
|
|
44
|
+
}
|
|
45
|
+
const meters = requireFiniteDistance(haversineMeters(coordinatesA, coordinatesB));
|
|
46
|
+
return { method, meters, kilometers: meters / 1000 };
|
|
47
|
+
}
|
|
48
|
+
/** GPS by default; supplying a resolver returns a Promise and never silently geocodes text. */
|
|
49
|
+
export function getDistance(pointA, pointB, options = {}) {
|
|
50
|
+
validateLocationOptions(options);
|
|
51
|
+
const method = getDistanceMethod(options.method);
|
|
52
|
+
const resolved = resolveLocationPoints([pointA, pointB], options.resolveAddress);
|
|
53
|
+
return resolved instanceof Promise
|
|
54
|
+
? resolved.then(points => calculateDistance(points, method)) : calculateDistance(resolved, method);
|
|
55
|
+
}
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { generateAddress } from "./address.js";
|
|
2
2
|
import { generateLegacyAddress } from "./legacy-address.js";
|
|
3
3
|
import { getDirectionsUrl } from "./directions.js";
|
|
4
|
+
import { getDistance } from "./distance.js";
|
|
5
|
+
import { optimizeRoute, ROUTE_OPTIMIZATION } from "./route.js";
|
|
4
6
|
import { getCountries, getCountry, getProvinces } from "./country.js";
|
|
5
7
|
import { ZAP_PROTOCOL, LEGACY_ZAP_PROTOCOL } from "./protocol.js";
|
|
6
|
-
export { generateAddress, generateLegacyAddress, getDirectionsUrl, getCountries, getCountry, getProvinces, ZAP_PROTOCOL, LEGACY_ZAP_PROTOCOL, };
|
|
7
|
-
export type { AddressInput, AddressResult, AddressEncoding, LegacyAddressInput, LegacyAddressResult, Coordinates, Corners, GridInput, Country, CountryOption, Province, ProvinceCountry, ProvinceOption, } from "./types.js";
|
|
8
|
+
export { generateAddress, generateLegacyAddress, getDirectionsUrl, getDistance, optimizeRoute, ROUTE_OPTIMIZATION, getCountries, getCountry, getProvinces, ZAP_PROTOCOL, LEGACY_ZAP_PROTOCOL, };
|
|
9
|
+
export type { AddressInput, AddressResult, AddressEncoding, LegacyAddressInput, LegacyAddressResult, Coordinates, Corners, GridInput, Country, CountryOption, Province, ProvinceCountry, ProvinceOption, SavedLocationPoint, LocationPoint, LocationInput, AddressResolver, DistanceMethod, RouteDistanceMethod, DistanceOptions, ResolvedDistanceOptions, DistanceResult, DistanceMatrix, RouteOptions, ResolvedRouteOptions, RouteLeg, RouteResult, } from "./types.js";
|
|
8
10
|
/** Named namespace for applications that refer to the protocol as `zap`. */
|
|
9
11
|
export declare const zap: Readonly<{
|
|
10
12
|
protocol: Readonly<{
|
|
@@ -21,6 +23,13 @@ export declare const zap: Readonly<{
|
|
|
21
23
|
generateAddress: typeof generateAddress;
|
|
22
24
|
generateLegacyAddress: typeof generateLegacyAddress;
|
|
23
25
|
getDirectionsUrl: typeof getDirectionsUrl;
|
|
26
|
+
getDistance: typeof getDistance;
|
|
27
|
+
optimizeRoute: typeof optimizeRoute;
|
|
28
|
+
routeOptimization: Readonly<{
|
|
29
|
+
readonly exactMaxStops: 12;
|
|
30
|
+
readonly maxStops: 1000;
|
|
31
|
+
readonly twoOptMaxPasses: 100;
|
|
32
|
+
}>;
|
|
24
33
|
getCountries: typeof getCountries;
|
|
25
34
|
getCountry: typeof getCountry;
|
|
26
35
|
getProvinces: typeof getProvinces;
|
package/dist/esm/index.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { generateAddress } from "./address.js";
|
|
2
2
|
import { generateLegacyAddress } from "./legacy-address.js";
|
|
3
3
|
import { getDirectionsUrl } from "./directions.js";
|
|
4
|
+
import { getDistance } from "./distance.js";
|
|
5
|
+
import { optimizeRoute, ROUTE_OPTIMIZATION } from "./route.js";
|
|
4
6
|
import { getCountries, getCountry, getProvinces } from "./country.js";
|
|
5
7
|
import { ZAP_PROTOCOL, LEGACY_ZAP_PROTOCOL } from "./protocol.js";
|
|
6
|
-
export { generateAddress, generateLegacyAddress, getDirectionsUrl, getCountries, getCountry, getProvinces, ZAP_PROTOCOL, LEGACY_ZAP_PROTOCOL, };
|
|
8
|
+
export { generateAddress, generateLegacyAddress, getDirectionsUrl, getDistance, optimizeRoute, ROUTE_OPTIMIZATION, getCountries, getCountry, getProvinces, ZAP_PROTOCOL, LEGACY_ZAP_PROTOCOL, };
|
|
7
9
|
/** Named namespace for applications that refer to the protocol as `zap`. */
|
|
8
10
|
export const zap = Object.freeze({
|
|
9
11
|
protocol: ZAP_PROTOCOL,
|
|
10
12
|
generateAddress,
|
|
11
13
|
generateLegacyAddress,
|
|
12
14
|
getDirectionsUrl,
|
|
15
|
+
getDistance,
|
|
16
|
+
optimizeRoute,
|
|
17
|
+
routeOptimization: ROUTE_OPTIMIZATION,
|
|
13
18
|
getCountries,
|
|
14
19
|
getCountry,
|
|
15
20
|
getProvinces,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AddressResolver, Coordinates, LocationInput, LocationPoint, SavedLocationPoint } from "./types.js";
|
|
2
|
+
export declare function validateLocationOptions(options: unknown): void;
|
|
3
|
+
/** Read original GPS only; never parse text, grid blocks, unit numbers, or suffixes. */
|
|
4
|
+
export declare function getLocationCoordinates(point: LocationPoint): Coordinates;
|
|
5
|
+
export declare function resolveLocationPoints(points: readonly LocationInput[], resolver: AddressResolver | undefined): LocationPoint[] | Promise<LocationPoint[]>;
|
|
6
|
+
export type ProjectedPoint = SavedLocationPoint & Readonly<{
|
|
7
|
+
gridId: string;
|
|
8
|
+
northingMeters: number;
|
|
9
|
+
eastingMeters: number;
|
|
10
|
+
}>;
|
|
11
|
+
export declare function getProjectedPoint(point: LocationPoint): ProjectedPoint;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { validateCoordinates } from "./grid.js";
|
|
2
|
+
export function validateLocationOptions(options) {
|
|
3
|
+
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
|
4
|
+
throw new TypeError("Options must be an object.");
|
|
5
|
+
}
|
|
6
|
+
if ("resolveAddress" in options && options.resolveAddress !== undefined && typeof options.resolveAddress !== "function") {
|
|
7
|
+
throw new TypeError("resolveAddress must be a function.");
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Read original GPS only; never parse text, grid blocks, unit numbers, or suffixes. */
|
|
11
|
+
export function getLocationCoordinates(point) {
|
|
12
|
+
if (!point || typeof point !== "object" || Array.isArray(point)) {
|
|
13
|
+
throw new TypeError("A latitude/longitude object or a saved record with coordinates is required.");
|
|
14
|
+
}
|
|
15
|
+
const coordinates = "coordinates" in point ? point.coordinates : point;
|
|
16
|
+
if (!coordinates || typeof coordinates !== "object" || Array.isArray(coordinates)) {
|
|
17
|
+
throw new TypeError("A saved record must contain its original GPS coordinates.");
|
|
18
|
+
}
|
|
19
|
+
validateCoordinates(coordinates);
|
|
20
|
+
return { latitude: coordinates.latitude, longitude: coordinates.longitude };
|
|
21
|
+
}
|
|
22
|
+
function validateResolvedRecord(record) {
|
|
23
|
+
if (!record || typeof record !== "object" || Array.isArray(record) || !("coordinates" in record)) {
|
|
24
|
+
throw new TypeError("resolveAddress must return a saved record with original GPS coordinates.");
|
|
25
|
+
}
|
|
26
|
+
getLocationCoordinates(record);
|
|
27
|
+
return record;
|
|
28
|
+
}
|
|
29
|
+
export function resolveLocationPoints(points, resolver) {
|
|
30
|
+
const checkString = (point) => {
|
|
31
|
+
if (!point.trim())
|
|
32
|
+
throw new TypeError("An address string must be nonempty.");
|
|
33
|
+
};
|
|
34
|
+
if (resolver !== undefined) {
|
|
35
|
+
// Promise.all installs rejection handlers for every asynchronous lookup.
|
|
36
|
+
return Promise.all(points.map(async (point, originalIndex) => {
|
|
37
|
+
if (typeof point === "string") {
|
|
38
|
+
checkString(point);
|
|
39
|
+
return validateResolvedRecord(await resolver(point, originalIndex));
|
|
40
|
+
}
|
|
41
|
+
getLocationCoordinates(point);
|
|
42
|
+
return point;
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
return points.map(point => {
|
|
46
|
+
if (typeof point === "string") {
|
|
47
|
+
checkString(point);
|
|
48
|
+
throw new TypeError("Address strings require resolveAddress; ZAP has no reverse decoder or automatic geocoder.");
|
|
49
|
+
}
|
|
50
|
+
getLocationCoordinates(point);
|
|
51
|
+
return point;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export function getProjectedPoint(point) {
|
|
55
|
+
if (!("coordinates" in point) || !("gridId" in point) || typeof point.gridId !== "string" || !point.gridId.trim()
|
|
56
|
+
|| !("northingMeters" in point) || !Number.isFinite(point.northingMeters)
|
|
57
|
+
|| !("eastingMeters" in point) || !Number.isFinite(point.eastingMeters)) {
|
|
58
|
+
throw new TypeError("Projected distance requires saved records with a gridId and finite northingMeters/eastingMeters.");
|
|
59
|
+
}
|
|
60
|
+
return point;
|
|
61
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LocationInput, LocationPoint, ResolvedRouteOptions, RouteOptions, RouteResult } from "./types.js";
|
|
2
|
+
/** Work limits are separate from address protocol/configuration versions. */
|
|
3
|
+
export declare const ROUTE_OPTIMIZATION: Readonly<{
|
|
4
|
+
readonly exactMaxStops: 12;
|
|
5
|
+
readonly maxStops: 1000;
|
|
6
|
+
readonly twoOptMaxPasses: 100;
|
|
7
|
+
}>;
|
|
8
|
+
export declare function optimizeRoute<T extends LocationPoint>(points: readonly T[], options?: RouteOptions): RouteResult<T>;
|
|
9
|
+
export declare function optimizeRoute<T extends LocationInput>(points: readonly T[], options: ResolvedRouteOptions): Promise<RouteResult<T>>;
|
|
10
|
+
export declare function optimizeRoute<T extends LocationPoint>(points: readonly T[], options: RouteOptions | ResolvedRouteOptions): RouteResult<T> | Promise<RouteResult<T>>;
|