monopolyline 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -120,3 +120,29 @@ When a schema is used or `includeTime`/`includeAccuracy` is enabled, the library
120
120
 
121
121
  For extreme data compression on massive payloads, this string output pairs perfectly with LZ-based algorithms like `lz-string` or standard `pako` (Deflate). Because Polyline outputs ASCII characters, it achieves very high Deflate ratios.
122
122
 
123
+
124
+ ### Merging Heterogeneous Chunks
125
+ You can merge multiple chunks encoded with different schemas or sparse strategies into a single unified polyline string by using `mergeChunks`. It automatically uses the fast-path for chunks that match the target format and falls back to full decode/re-encode for chunks that differ.
126
+
127
+ ```typescript
128
+ import { mergeChunks } from 'monopolyline';
129
+
130
+ // Create chunks with different options
131
+ const chunk1 = {
132
+ data: encodedString1,
133
+ options: { schema: { type: 'object', fields: { lat: 'decimal', lng: 'decimal', speed: 'integer' } }, sparseStrategy: 'bitmap' }
134
+ };
135
+
136
+ const chunk2 = {
137
+ data: encodedString2,
138
+ options: { schema: { type: 'object', fields: { lat: 'decimal', lng: 'decimal', temp: 'integer' } }, sparseStrategy: 'naive' }
139
+ };
140
+
141
+ // Target format needs to encompass both
142
+ const targetOptions = {
143
+ schema: { type: 'object', fields: { lat: 'decimal', lng: 'decimal', speed: 'integer', temp: 'integer' } },
144
+ sparseStrategy: 'bitmap'
145
+ };
146
+
147
+ const mergedStr = mergeChunks([chunk1, chunk2], targetOptions);
148
+ ```
@@ -0,0 +1,56 @@
1
+ export type FieldType = 'decimal' | 'integer';
2
+ export type Schema = {
3
+ type: 'object';
4
+ fields: Record<string, FieldType>;
5
+ } | {
6
+ type: 'tuple';
7
+ fields: FieldType[];
8
+ };
9
+ export declare const DEFAULT_SCHEMA: Schema;
10
+ export declare const DEFAULT_TUPLE_SCHEMA: Schema;
11
+ export interface Point {
12
+ lat: number;
13
+ lng: number;
14
+ time?: number;
15
+ acc?: number;
16
+ }
17
+ export type SparseStrategy = 'naive' | 'bitmap' | 'columnar';
18
+ export interface EncodeOptions {
19
+ /**
20
+ * Number of decimal places for 'decimal' fields.
21
+ * Default: 5 (Standard Google Polyline)
22
+ */
23
+ precision?: number;
24
+ /**
25
+ * Schema definition to make encoding/decoding generic.
26
+ * If provided, `includeTime` and `includeAccuracy` are ignored.
27
+ */
28
+ schema?: Schema;
29
+ /**
30
+ * Include the time dimension in the encoded polyline.
31
+ * Default: false
32
+ * @deprecated Use `schema` instead for generic data structures.
33
+ */
34
+ includeTime?: boolean;
35
+ /**
36
+ * Include the accuracy dimension in the encoded polyline.
37
+ * Default: false
38
+ * @deprecated Use `schema` instead for generic data structures.
39
+ */
40
+ includeAccuracy?: boolean;
41
+ /**
42
+ * Strategy for encoding sparse data (missing values).
43
+ * 'naive' (default): Encodes missing values as 0 delta (repeats previous value).
44
+ * 'bitmap': Adds a small presence bitmask to each point to skip missing values.
45
+ * 'columnar': Splits the array into columns (lats, lngs, etc) with run-length presence flags.
46
+ */
47
+ sparseStrategy?: SparseStrategy;
48
+ }
49
+ /**
50
+ * Encodes an array of data into an extended polyline string.
51
+ */
52
+ export declare function encode(data: any[], options?: EncodeOptions): string;
53
+ /**
54
+ * Decodes an extended polyline string back into an array of data.
55
+ */
56
+ export declare function decode(str: string, options?: EncodeOptions): any[];
package/dist/index.js ADDED
@@ -0,0 +1,473 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_TUPLE_SCHEMA = exports.DEFAULT_SCHEMA = void 0;
4
+ exports.encode = encode;
5
+ exports.decode = decode;
6
+ exports.DEFAULT_SCHEMA = {
7
+ type: 'object',
8
+ fields: {
9
+ lat: 'decimal',
10
+ lng: 'decimal'
11
+ }
12
+ };
13
+ exports.DEFAULT_TUPLE_SCHEMA = {
14
+ type: 'tuple',
15
+ fields: ['decimal', 'decimal']
16
+ };
17
+ /**
18
+ * Encodes a numeric value using ZigZag encoding and base64-like chunking.
19
+ */
20
+ function encodeNumber(value) {
21
+ let zz = value < 0 ? -value * 2 - 1 : value * 2;
22
+ let str = "";
23
+ while (zz >= 0x20) {
24
+ str += String.fromCharCode((0x20 | (zz & 0x1f)) + 63);
25
+ zz = Math.floor(zz / 32);
26
+ }
27
+ str += String.fromCharCode(zz + 63);
28
+ return str;
29
+ }
30
+ /**
31
+ * Decodes a single numeric value from the encoded string starting at the given index.
32
+ */
33
+ function decodeNumber(str, index) {
34
+ let b = 0;
35
+ let result = 0;
36
+ let factor = 1;
37
+ do {
38
+ if (index >= str.length) {
39
+ throw new Error("Invalid polyline string: unexpected end of string");
40
+ }
41
+ b = str.charCodeAt(index++) - 63;
42
+ result += (b & 0x1f) * factor;
43
+ factor *= 32;
44
+ } while (b >= 0x20);
45
+ const value = result % 2 === 1 ? -(Math.floor(result / 2) + 1) : Math.floor(result / 2);
46
+ return [value, index];
47
+ }
48
+ /**
49
+ * Encodes an array of data into an extended polyline string.
50
+ */
51
+ function encode(data, options = {}) {
52
+ if (!options.schema) {
53
+ return encodeLegacy(data, options);
54
+ }
55
+ return encodeGeneric(data, options);
56
+ }
57
+ /**
58
+ * Decodes an extended polyline string back into an array of data.
59
+ */
60
+ function decode(str, options = {}) {
61
+ if (!options.schema) {
62
+ return decodeLegacy(str, options);
63
+ }
64
+ return decodeGeneric(str, options);
65
+ }
66
+ function encodeGeneric(data, options) {
67
+ var _a, _b;
68
+ const precision = (_a = options.precision) !== null && _a !== void 0 ? _a : 5;
69
+ const factor = Math.pow(10, precision);
70
+ const sparseStrategy = (_b = options.sparseStrategy) !== null && _b !== void 0 ? _b : 'naive';
71
+ const schema = options.schema;
72
+ const schemaFields = [];
73
+ if (schema.type === 'object') {
74
+ for (const key of Object.keys(schema.fields)) {
75
+ schemaFields.push({ key, type: schema.fields[key] });
76
+ }
77
+ }
78
+ else {
79
+ for (let i = 0; i < schema.fields.length; i++) {
80
+ schemaFields.push({ key: i, type: schema.fields[i] });
81
+ }
82
+ }
83
+ let result = "";
84
+ if (sparseStrategy === 'columnar') {
85
+ result += encodeNumber(data.length);
86
+ for (let i = 0; i < schemaFields.length; i++) {
87
+ const field = schemaFields[i];
88
+ let prev = 0;
89
+ let presence = 0;
90
+ let bitIndex = 0;
91
+ let presenceStrs = "";
92
+ let dataStrs = "";
93
+ for (const pt of data) {
94
+ const val = pt[field.key];
95
+ if (val != null) {
96
+ presence |= (1 << bitIndex);
97
+ const scaled = field.type === 'decimal' ? Math.round(val * factor) : Math.round(val);
98
+ dataStrs += encodeNumber(scaled - prev);
99
+ prev = scaled;
100
+ }
101
+ bitIndex++;
102
+ if (bitIndex === 30) {
103
+ presenceStrs += encodeNumber(presence);
104
+ presence = 0;
105
+ bitIndex = 0;
106
+ }
107
+ }
108
+ if (bitIndex > 0)
109
+ presenceStrs += encodeNumber(presence);
110
+ result += presenceStrs + dataStrs;
111
+ }
112
+ return result;
113
+ }
114
+ // naive or bitmap
115
+ const prevs = new Array(schemaFields.length).fill(0);
116
+ for (const pt of data) {
117
+ if (sparseStrategy === 'bitmap') {
118
+ let bitmask = 0;
119
+ for (let i = 0; i < schemaFields.length; i++) {
120
+ if (pt[schemaFields[i].key] != null)
121
+ bitmask |= (1 << i);
122
+ }
123
+ result += encodeNumber(bitmask);
124
+ }
125
+ for (let i = 0; i < schemaFields.length; i++) {
126
+ const field = schemaFields[i];
127
+ const val = pt[field.key];
128
+ if (sparseStrategy === 'bitmap') {
129
+ if (val != null) {
130
+ const scaled = field.type === 'decimal' ? Math.round(val * factor) : Math.round(val);
131
+ result += encodeNumber(scaled - prevs[i]);
132
+ prevs[i] = scaled;
133
+ }
134
+ }
135
+ else {
136
+ // naive
137
+ if (val != null) {
138
+ const scaled = field.type === 'decimal' ? Math.round(val * factor) : Math.round(val);
139
+ result += encodeNumber(scaled - prevs[i]);
140
+ prevs[i] = scaled;
141
+ }
142
+ else {
143
+ result += encodeNumber(0);
144
+ }
145
+ }
146
+ }
147
+ }
148
+ return result;
149
+ }
150
+ function decodeGeneric(str, options) {
151
+ var _a, _b;
152
+ const precision = (_a = options.precision) !== null && _a !== void 0 ? _a : 5;
153
+ const factor = Math.pow(10, precision);
154
+ const sparseStrategy = (_b = options.sparseStrategy) !== null && _b !== void 0 ? _b : 'naive';
155
+ const schema = options.schema;
156
+ const schemaFields = [];
157
+ if (schema.type === 'object') {
158
+ for (const key of Object.keys(schema.fields)) {
159
+ schemaFields.push({ key, type: schema.fields[key] });
160
+ }
161
+ }
162
+ else {
163
+ for (let i = 0; i < schema.fields.length; i++) {
164
+ schemaFields.push({ key: i, type: schema.fields[i] });
165
+ }
166
+ }
167
+ let index = 0;
168
+ if (sparseStrategy === 'columnar') {
169
+ if (str.length === 0)
170
+ return [];
171
+ let ptCount = 0;
172
+ [ptCount, index] = decodeNumber(str, index);
173
+ const results = Array.from({ length: ptCount }, () => (schema.type === 'tuple' ? [] : {}));
174
+ for (let f = 0; f < schemaFields.length; f++) {
175
+ const field = schemaFields[f];
176
+ const presenceFlags = [];
177
+ const numPresenceInts = Math.ceil(ptCount / 30);
178
+ for (let i = 0; i < numPresenceInts; i++) {
179
+ let p = 0;
180
+ [p, index] = decodeNumber(str, index);
181
+ for (let b = 0; b < 30 && (i * 30 + b) < ptCount; b++) {
182
+ presenceFlags.push((p & (1 << b)) !== 0);
183
+ }
184
+ }
185
+ let prev = 0;
186
+ for (let i = 0; i < ptCount; i++) {
187
+ if (presenceFlags[i]) {
188
+ let delta = 0;
189
+ [delta, index] = decodeNumber(str, index);
190
+ prev += delta;
191
+ const val = field.type === 'decimal' ? prev / factor : prev;
192
+ results[i][field.key] = val;
193
+ }
194
+ }
195
+ }
196
+ return results;
197
+ }
198
+ const prevs = new Array(schemaFields.length).fill(0);
199
+ const results = [];
200
+ while (index < str.length) {
201
+ let bitmask = (1 << schemaFields.length) - 1; // default all present
202
+ if (sparseStrategy === 'bitmap') {
203
+ [bitmask, index] = decodeNumber(str, index);
204
+ }
205
+ const pt = schema.type === 'tuple' ? [] : {};
206
+ for (let f = 0; f < schemaFields.length; f++) {
207
+ const field = schemaFields[f];
208
+ if (sparseStrategy === 'bitmap') {
209
+ if ((bitmask & (1 << f)) !== 0) {
210
+ let delta = 0;
211
+ [delta, index] = decodeNumber(str, index);
212
+ prevs[f] += delta;
213
+ pt[field.key] = field.type === 'decimal' ? prevs[f] / factor : prevs[f];
214
+ }
215
+ }
216
+ else {
217
+ // naive
218
+ let delta = 0;
219
+ [delta, index] = decodeNumber(str, index);
220
+ prevs[f] += delta;
221
+ pt[field.key] = field.type === 'decimal' ? prevs[f] / factor : prevs[f];
222
+ }
223
+ }
224
+ results.push(pt);
225
+ }
226
+ return results;
227
+ }
228
+ function encodeLegacy(points, options) {
229
+ var _a, _b, _c, _d;
230
+ const precision = (_a = options.precision) !== null && _a !== void 0 ? _a : 5;
231
+ const factor = Math.pow(10, precision);
232
+ const includeTime = (_b = options.includeTime) !== null && _b !== void 0 ? _b : false;
233
+ const includeAccuracy = (_c = options.includeAccuracy) !== null && _c !== void 0 ? _c : false;
234
+ const sparseStrategy = (_d = options.sparseStrategy) !== null && _d !== void 0 ? _d : 'naive';
235
+ let result = "";
236
+ if (sparseStrategy === 'columnar') {
237
+ result += encodeNumber(points.length);
238
+ let prevLat = 0, prevLng = 0;
239
+ for (const point of points) {
240
+ const lat = Math.round(point.lat * factor);
241
+ const lng = Math.round(point.lng * factor);
242
+ result += encodeNumber(lat - prevLat);
243
+ result += encodeNumber(lng - prevLng);
244
+ prevLat = lat;
245
+ prevLng = lng;
246
+ }
247
+ if (includeTime) {
248
+ let prevTime = 0;
249
+ let timePresence = 0;
250
+ let bitIndex = 0;
251
+ let presenceStrs = "";
252
+ let dataStrs = "";
253
+ for (const point of points) {
254
+ if (point.time != null) {
255
+ timePresence |= (1 << bitIndex);
256
+ const time = Math.round(point.time);
257
+ dataStrs += encodeNumber(time - prevTime);
258
+ prevTime = time;
259
+ }
260
+ bitIndex++;
261
+ if (bitIndex === 30) {
262
+ presenceStrs += encodeNumber(timePresence);
263
+ timePresence = 0;
264
+ bitIndex = 0;
265
+ }
266
+ }
267
+ if (bitIndex > 0)
268
+ presenceStrs += encodeNumber(timePresence);
269
+ result += presenceStrs + dataStrs;
270
+ }
271
+ if (includeAccuracy) {
272
+ let prevAcc = 0;
273
+ let accPresence = 0;
274
+ let bitIndex = 0;
275
+ let presenceStrs = "";
276
+ let dataStrs = "";
277
+ for (const point of points) {
278
+ if (point.acc != null) {
279
+ accPresence |= (1 << bitIndex);
280
+ const acc = Math.round(point.acc);
281
+ dataStrs += encodeNumber(acc - prevAcc);
282
+ prevAcc = acc;
283
+ }
284
+ bitIndex++;
285
+ if (bitIndex === 30) {
286
+ presenceStrs += encodeNumber(accPresence);
287
+ accPresence = 0;
288
+ bitIndex = 0;
289
+ }
290
+ }
291
+ if (bitIndex > 0)
292
+ presenceStrs += encodeNumber(accPresence);
293
+ result += presenceStrs + dataStrs;
294
+ }
295
+ return result;
296
+ }
297
+ let prevLat = 0;
298
+ let prevLng = 0;
299
+ let prevTime = 0;
300
+ let prevAcc = 0;
301
+ for (const point of points) {
302
+ const lat = Math.round(point.lat * factor);
303
+ const lng = Math.round(point.lng * factor);
304
+ if (sparseStrategy === 'bitmap' && (includeTime || includeAccuracy)) {
305
+ let bitmask = 0;
306
+ if (includeTime && point.time != null)
307
+ bitmask |= 1;
308
+ if (includeAccuracy && point.acc != null)
309
+ bitmask |= 2;
310
+ result += encodeNumber(bitmask);
311
+ }
312
+ result += encodeNumber(lat - prevLat);
313
+ result += encodeNumber(lng - prevLng);
314
+ prevLat = lat;
315
+ prevLng = lng;
316
+ if (sparseStrategy === 'bitmap') {
317
+ if (includeTime && point.time != null) {
318
+ const time = Math.round(point.time);
319
+ result += encodeNumber(time - prevTime);
320
+ prevTime = time;
321
+ }
322
+ if (includeAccuracy && point.acc != null) {
323
+ const acc = Math.round(point.acc);
324
+ result += encodeNumber(acc - prevAcc);
325
+ prevAcc = acc;
326
+ }
327
+ }
328
+ else {
329
+ if (includeTime) {
330
+ if (point.time != null) {
331
+ const time = Math.round(point.time);
332
+ result += encodeNumber(time - prevTime);
333
+ prevTime = time;
334
+ }
335
+ else {
336
+ result += encodeNumber(0);
337
+ }
338
+ }
339
+ if (includeAccuracy) {
340
+ if (point.acc != null) {
341
+ const acc = Math.round(point.acc);
342
+ result += encodeNumber(acc - prevAcc);
343
+ prevAcc = acc;
344
+ }
345
+ else {
346
+ result += encodeNumber(0);
347
+ }
348
+ }
349
+ }
350
+ }
351
+ return result;
352
+ }
353
+ function decodeLegacy(str, options) {
354
+ var _a, _b, _c, _d;
355
+ const precision = (_a = options.precision) !== null && _a !== void 0 ? _a : 5;
356
+ const factor = Math.pow(10, precision);
357
+ const includeTime = (_b = options.includeTime) !== null && _b !== void 0 ? _b : false;
358
+ const includeAccuracy = (_c = options.includeAccuracy) !== null && _c !== void 0 ? _c : false;
359
+ const sparseStrategy = (_d = options.sparseStrategy) !== null && _d !== void 0 ? _d : 'naive';
360
+ let index = 0;
361
+ const points = [];
362
+ if (sparseStrategy === 'columnar') {
363
+ if (str.length === 0)
364
+ return points;
365
+ let ptCount = 0;
366
+ [ptCount, index] = decodeNumber(str, index);
367
+ let prevLat = 0;
368
+ let prevLng = 0;
369
+ for (let i = 0; i < ptCount; i++) {
370
+ let dLat = 0, dLng = 0;
371
+ [dLat, index] = decodeNumber(str, index);
372
+ [dLng, index] = decodeNumber(str, index);
373
+ prevLat += dLat;
374
+ prevLng += dLng;
375
+ points.push({ lat: prevLat / factor, lng: prevLng / factor });
376
+ }
377
+ if (includeTime) {
378
+ const presenceFlags = [];
379
+ const numPresenceInts = Math.ceil(ptCount / 30);
380
+ for (let i = 0; i < numPresenceInts; i++) {
381
+ let p = 0;
382
+ [p, index] = decodeNumber(str, index);
383
+ for (let b = 0; b < 30 && (i * 30 + b) < ptCount; b++) {
384
+ presenceFlags.push((p & (1 << b)) !== 0);
385
+ }
386
+ }
387
+ let prevTime = 0;
388
+ for (let i = 0; i < ptCount; i++) {
389
+ if (presenceFlags[i]) {
390
+ let dTime = 0;
391
+ [dTime, index] = decodeNumber(str, index);
392
+ prevTime += dTime;
393
+ points[i].time = prevTime;
394
+ }
395
+ }
396
+ }
397
+ if (includeAccuracy) {
398
+ const presenceFlags = [];
399
+ const numPresenceInts = Math.ceil(ptCount / 30);
400
+ for (let i = 0; i < numPresenceInts; i++) {
401
+ let p = 0;
402
+ [p, index] = decodeNumber(str, index);
403
+ for (let b = 0; b < 30 && (i * 30 + b) < ptCount; b++) {
404
+ presenceFlags.push((p & (1 << b)) !== 0);
405
+ }
406
+ }
407
+ let prevAcc = 0;
408
+ for (let i = 0; i < ptCount; i++) {
409
+ if (presenceFlags[i]) {
410
+ let dAcc = 0;
411
+ [dAcc, index] = decodeNumber(str, index);
412
+ prevAcc += dAcc;
413
+ points[i].acc = prevAcc;
414
+ }
415
+ }
416
+ }
417
+ return points;
418
+ }
419
+ let prevLat = 0;
420
+ let prevLng = 0;
421
+ let prevTime = 0;
422
+ let prevAcc = 0;
423
+ while (index < str.length) {
424
+ let bitmask = 3;
425
+ if (sparseStrategy === 'bitmap' && (includeTime || includeAccuracy)) {
426
+ [bitmask, index] = decodeNumber(str, index);
427
+ }
428
+ let dLat = 0;
429
+ let dLng = 0;
430
+ [dLat, index] = decodeNumber(str, index);
431
+ [dLng, index] = decodeNumber(str, index);
432
+ prevLat += dLat;
433
+ prevLng += dLng;
434
+ const point = {
435
+ lat: prevLat / factor,
436
+ lng: prevLng / factor,
437
+ };
438
+ if (sparseStrategy === 'bitmap') {
439
+ if (includeTime && (bitmask & 1)) {
440
+ let dTime = 0;
441
+ [dTime, index] = decodeNumber(str, index);
442
+ prevTime += dTime;
443
+ point.time = prevTime;
444
+ }
445
+ if (includeAccuracy && (bitmask & 2)) {
446
+ let dAcc = 0;
447
+ [dAcc, index] = decodeNumber(str, index);
448
+ prevAcc += dAcc;
449
+ point.acc = prevAcc;
450
+ }
451
+ }
452
+ else {
453
+ if (includeTime) {
454
+ let dTime = 0;
455
+ [dTime, index] = decodeNumber(str, index);
456
+ if (dTime !== 0) {
457
+ prevTime += dTime;
458
+ point.time = prevTime;
459
+ }
460
+ }
461
+ if (includeAccuracy) {
462
+ let dAcc = 0;
463
+ [dAcc, index] = decodeNumber(str, index);
464
+ if (dAcc !== 0) {
465
+ prevAcc += dAcc;
466
+ point.acc = prevAcc;
467
+ }
468
+ }
469
+ }
470
+ points.push(point);
471
+ }
472
+ return points;
473
+ }
package/index.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
- import { encode, decode, EncodeOptions, Schema } from './index';
2
+ import { encode, decode, merge, mergeMany, mergeChunks, EncodeOptions, Schema } from './index';
3
3
 
4
4
  describe('monopolyline backwards compatibility', () => {
5
5
  it('encodes and decodes lat/lng successfully (naive)', () => {
@@ -140,3 +140,142 @@ describe('monopolyline generic schema', () => {
140
140
  expect(decoded[2][3]).toBeUndefined();
141
141
  });
142
142
  });
143
+
144
+ describe('monopolyline merge', () => {
145
+ it('merges naive polylines correctly', () => {
146
+ const pointsA = [{ lat: 38.5, lng: -120.2 }, { lat: 40.7, lng: -120.95 }];
147
+ const pointsB = [{ lat: 43.252, lng: -126.453 }, { lat: 44.0, lng: -127.0 }];
148
+ const encodedA = encode(pointsA);
149
+ const encodedB = encode(pointsB);
150
+ const merged = merge(encodedA, encodedB);
151
+ const decoded = decode(merged);
152
+
153
+ expect(decoded.length).toBe(4);
154
+ expect(decoded[1].lat).toBeCloseTo(40.7, 5);
155
+ expect(decoded[2].lat).toBeCloseTo(43.252, 5);
156
+ expect(decoded[3].lat).toBeCloseTo(44.0, 5);
157
+ });
158
+
159
+ it('merges bitmap sparse polylines correctly', () => {
160
+ const dataA = [
161
+ { lat: 38.5, lng: -120.2, speed: 50 },
162
+ { lat: 40.7, lng: -120.95 }
163
+ ];
164
+ const dataB = [
165
+ { lat: 43.2, lng: -126.4, speed: 60 },
166
+ { lat: 44.0, lng: -127.0 }
167
+ ];
168
+
169
+ const opts: EncodeOptions = {
170
+ schema: {
171
+ type: 'object',
172
+ fields: {
173
+ lat: 'decimal',
174
+ lng: 'decimal',
175
+ speed: 'integer'
176
+ }
177
+ },
178
+ sparseStrategy: 'bitmap'
179
+ };
180
+
181
+ const encodedA = encode(dataA, opts);
182
+ const encodedB = encode(dataB, opts);
183
+ const merged = merge(encodedA, encodedB, opts);
184
+ const decoded = decode(merged, opts);
185
+
186
+ expect(decoded.length).toBe(4);
187
+ expect(decoded[0].speed).toBe(50);
188
+ expect(decoded[1].speed).toBeUndefined();
189
+ expect(decoded[2].speed).toBe(60);
190
+ expect(decoded[3].speed).toBeUndefined();
191
+ });
192
+
193
+ it('merges columnar sparse polylines correctly', () => {
194
+ const dataA = [
195
+ { lat: 38.5, lng: -120.2, speed: 50 },
196
+ { lat: 40.7, lng: -120.95 }
197
+ ];
198
+ const dataB = [
199
+ { lat: 43.2, lng: -126.4, speed: 60 },
200
+ { lat: 44.0, lng: -127.0 }
201
+ ];
202
+
203
+ const opts: EncodeOptions = {
204
+ schema: {
205
+ type: 'object',
206
+ fields: {
207
+ lat: 'decimal',
208
+ lng: 'decimal',
209
+ speed: 'integer'
210
+ }
211
+ },
212
+ sparseStrategy: 'columnar'
213
+ };
214
+
215
+ const encodedA = encode(dataA, opts);
216
+ const encodedB = encode(dataB, opts);
217
+
218
+ // Suppress warning for testing
219
+ const merged = merge(encodedA, encodedB, { ...opts, suppressWarnings: true });
220
+ const decoded = decode(merged, opts);
221
+
222
+ expect(decoded.length).toBe(4);
223
+ expect(decoded[0].speed).toBe(50);
224
+ expect(decoded[1].speed).toBeUndefined();
225
+ expect(decoded[2].speed).toBe(60);
226
+ expect(decoded[3].speed).toBeUndefined();
227
+ });
228
+ });
229
+
230
+ describe('monopolyline mergeChunks', () => {
231
+ it('merges heterogeneous chunks correctly', () => {
232
+ // Chunk 1: bitmap sparse, has speed
233
+ const dataA = [
234
+ { lat: 38.5, lng: -120.2, speed: 50 },
235
+ { lat: 40.7, lng: -120.95 }
236
+ ];
237
+ const optsA: EncodeOptions = {
238
+ schema: { type: 'object', fields: { lat: 'decimal', lng: 'decimal', speed: 'integer' } },
239
+ sparseStrategy: 'bitmap'
240
+ };
241
+ const encodedA = encode(dataA, optsA);
242
+
243
+ // Chunk 2: naive, has heading instead of speed
244
+ const dataB = [
245
+ { lat: 43.2, lng: -126.4, heading: 90 },
246
+ { lat: 44.0, lng: -127.0, heading: 180 }
247
+ ];
248
+ const optsB: EncodeOptions = {
249
+ schema: { type: 'object', fields: { lat: 'decimal', lng: 'decimal', heading: 'integer' } },
250
+ sparseStrategy: 'naive'
251
+ };
252
+ const encodedB = encode(dataB, optsB);
253
+
254
+ // Target format: needs speed and heading, bitmap
255
+ const targetOpts: EncodeOptions = {
256
+ schema: { type: 'object', fields: { lat: 'decimal', lng: 'decimal', speed: 'integer', heading: 'integer' } },
257
+ sparseStrategy: 'bitmap'
258
+ };
259
+
260
+ const chunks = [
261
+ { data: encodedA, options: optsA },
262
+ { data: encodedB, options: optsB }
263
+ ];
264
+
265
+ const merged = mergeChunks(chunks, targetOpts);
266
+ const decoded = decode(merged, targetOpts);
267
+
268
+ expect(decoded.length).toBe(4);
269
+ expect(decoded[0].speed).toBe(50);
270
+ expect(decoded[0].heading).toBeUndefined();
271
+ expect(decoded[1].speed).toBeUndefined();
272
+
273
+ expect(decoded[2].speed).toBeUndefined();
274
+ expect(decoded[2].heading).toBe(90);
275
+ expect(decoded[3].heading).toBe(180);
276
+
277
+ // Check points
278
+ expect(decoded[1].lat).toBeCloseTo(40.7, 5);
279
+ expect(decoded[2].lat).toBeCloseTo(43.2, 5);
280
+ });
281
+ });
package/index.ts CHANGED
@@ -26,6 +26,10 @@ export interface Point {
26
26
 
27
27
  export type SparseStrategy = 'naive' | 'bitmap' | 'columnar';
28
28
 
29
+ export interface EncodeState {
30
+ values: number[];
31
+ }
32
+
29
33
  export interface EncodeOptions {
30
34
  /**
31
35
  * Number of decimal places for 'decimal' fields.
@@ -60,6 +64,31 @@ export interface EncodeOptions {
60
64
  * 'columnar': Splits the array into columns (lats, lngs, etc) with run-length presence flags.
61
65
  */
62
66
  sparseStrategy?: SparseStrategy;
67
+
68
+ /**
69
+ * Internal state used for merging polylines.
70
+ */
71
+ initialState?: EncodeState;
72
+ }
73
+
74
+ export interface MergeOptions extends Omit<EncodeOptions, 'initialState'> {
75
+ /**
76
+ * Suppress warnings when merging 'columnar' sparseStrategy.
77
+ */
78
+ suppressWarnings?: boolean;
79
+ }
80
+
81
+ export interface EncodedChunk {
82
+ /**
83
+ * The encoded polyline string for this chunk.
84
+ */
85
+ data: string;
86
+
87
+ /**
88
+ * The encoding options used for this specific chunk.
89
+ * If omitted, it is assumed to match the targetOptions of the merge.
90
+ */
91
+ options?: EncodeOptions;
63
92
  }
64
93
 
65
94
  /**
@@ -168,7 +197,7 @@ function encodeGeneric(data: any[], options: EncodeOptions): string {
168
197
  }
169
198
 
170
199
  // naive or bitmap
171
- const prevs = new Array(schemaFields.length).fill(0);
200
+ const prevs = options.initialState?.values ? [...options.initialState.values] : new Array(schemaFields.length).fill(0);
172
201
 
173
202
  for (const pt of data) {
174
203
  if (sparseStrategy === 'bitmap') {
@@ -364,10 +393,10 @@ function encodeLegacy(points: Point[], options: EncodeOptions): string {
364
393
  return result;
365
394
  }
366
395
 
367
- let prevLat = 0;
368
- let prevLng = 0;
369
- let prevTime = 0;
370
- let prevAcc = 0;
396
+ let prevLat = options.initialState?.values[0] ?? 0;
397
+ let prevLng = options.initialState?.values[1] ?? 0;
398
+ let prevTime = options.initialState?.values[2] ?? 0;
399
+ let prevAcc = options.initialState?.values[3] ?? 0;
371
400
 
372
401
  for (const point of points) {
373
402
  const lat = Math.round(point.lat * factor);
@@ -553,3 +582,181 @@ function decodeLegacy(str: string, options: EncodeOptions): Point[] {
553
582
  return points;
554
583
  }
555
584
 
585
+ export function getFinalState(str: string, options: EncodeOptions = {}): EncodeState {
586
+ const precision = options.precision ?? 5;
587
+ const sparseStrategy = options.sparseStrategy ?? 'naive';
588
+
589
+ if (str.length === 0 || sparseStrategy === 'columnar') {
590
+ return { values: [] };
591
+ }
592
+
593
+ let index = 0;
594
+
595
+ if (options.schema) {
596
+ const schema = options.schema;
597
+ const numFields = schema.type === 'object' ? Object.keys(schema.fields).length : schema.fields.length;
598
+ const prevs = new Array(numFields).fill(0);
599
+
600
+ while (index < str.length) {
601
+ let bitmask = (1 << numFields) - 1;
602
+ if (sparseStrategy === 'bitmap') {
603
+ [bitmask, index] = decodeNumber(str, index);
604
+ }
605
+
606
+ for (let f = 0; f < numFields; f++) {
607
+ if (sparseStrategy === 'bitmap') {
608
+ if ((bitmask & (1 << f)) !== 0) {
609
+ let delta = 0;
610
+ [delta, index] = decodeNumber(str, index);
611
+ prevs[f] += delta;
612
+ }
613
+ } else {
614
+ let delta = 0;
615
+ [delta, index] = decodeNumber(str, index);
616
+ prevs[f] += delta;
617
+ }
618
+ }
619
+ }
620
+ return { values: prevs };
621
+ } else {
622
+ const includeTime = options.includeTime ?? false;
623
+ const includeAccuracy = options.includeAccuracy ?? false;
624
+ let prevLat = 0, prevLng = 0, prevTime = 0, prevAcc = 0;
625
+
626
+ while (index < str.length) {
627
+ let bitmask = 3;
628
+ if (sparseStrategy === 'bitmap' && (includeTime || includeAccuracy)) {
629
+ [bitmask, index] = decodeNumber(str, index);
630
+ }
631
+
632
+ let dLat = 0, dLng = 0;
633
+ [dLat, index] = decodeNumber(str, index);
634
+ [dLng, index] = decodeNumber(str, index);
635
+ prevLat += dLat;
636
+ prevLng += dLng;
637
+
638
+ if (sparseStrategy === 'bitmap') {
639
+ if (includeTime && (bitmask & 1)) {
640
+ let dTime = 0;
641
+ [dTime, index] = decodeNumber(str, index);
642
+ prevTime += dTime;
643
+ }
644
+ if (includeAccuracy && (bitmask & 2)) {
645
+ let dAcc = 0;
646
+ [dAcc, index] = decodeNumber(str, index);
647
+ prevAcc += dAcc;
648
+ }
649
+ } else {
650
+ if (includeTime) {
651
+ let dTime = 0;
652
+ [dTime, index] = decodeNumber(str, index);
653
+ if (dTime !== 0) prevTime += dTime;
654
+ }
655
+ if (includeAccuracy) {
656
+ let dAcc = 0;
657
+ [dAcc, index] = decodeNumber(str, index);
658
+ if (dAcc !== 0) prevAcc += dAcc;
659
+ }
660
+ }
661
+ }
662
+ return { values: [prevLat, prevLng, prevTime, prevAcc] };
663
+ }
664
+ }
665
+
666
+ export function merge(encodedA: string, encodedB: string, options: MergeOptions = {}): string {
667
+ return mergeMany([encodedA, encodedB], options);
668
+ }
669
+
670
+ export function mergeMany(encodedList: string[], options: MergeOptions = {}): string {
671
+ const list = encodedList.filter(s => s.length > 0);
672
+ if (list.length === 0) return "";
673
+ if (list.length === 1) return list[0];
674
+
675
+ const sparseStrategy = options.sparseStrategy ?? 'naive';
676
+
677
+ if (sparseStrategy === 'columnar') {
678
+ if (!options.suppressWarnings) {
679
+ console.warn("Merging 'columnar' encoded strings requires a full decode and re-encode. Pass { suppressWarnings: true } to suppress this warning.");
680
+ }
681
+ // Full decode and encode
682
+ const allPoints: any[] = [];
683
+ for (const str of list) {
684
+ const decoded = decode(str, options);
685
+ allPoints.push(...decoded);
686
+ }
687
+ return encode(allPoints, options);
688
+ }
689
+
690
+ // Naive or Bitmap: fast path merging
691
+ let result = list[0];
692
+ let currentState = getFinalState(result, options);
693
+
694
+ for (let i = 1; i < list.length; i++) {
695
+ const strB = list[i];
696
+ const pointsB = decode(strB, options);
697
+
698
+ const encodeOpts: EncodeOptions = {
699
+ ...options,
700
+ initialState: currentState
701
+ };
702
+
703
+ const encodedB = encode(pointsB, encodeOpts);
704
+ result += encodedB;
705
+
706
+ // Update state for the next merge
707
+ currentState = getFinalState(encodedB, encodeOpts);
708
+ }
709
+
710
+ return result;
711
+ }
712
+
713
+ export function mergeChunks(chunks: EncodedChunk[], targetOptions: MergeOptions = {}): string {
714
+ const list = chunks.filter(c => c.data.length > 0);
715
+ if (list.length === 0) return "";
716
+
717
+ const targetStrategy = targetOptions.sparseStrategy ?? 'naive';
718
+
719
+ if (targetStrategy === 'columnar') {
720
+ if (!targetOptions.suppressWarnings) {
721
+ console.warn("Merging into 'columnar' requires full decode and re-encode. Pass { suppressWarnings: true } to suppress this warning.");
722
+ }
723
+ const allPoints: any[] = [];
724
+ for (const chunk of list) {
725
+ const decoded = decode(chunk.data, chunk.options ?? targetOptions);
726
+ allPoints.push(...decoded);
727
+ }
728
+ return encode(allPoints, targetOptions);
729
+ }
730
+
731
+ let result = "";
732
+ let currentState: EncodeState | undefined = undefined;
733
+
734
+ for (let i = 0; i < list.length; i++) {
735
+ const chunk = list[i];
736
+ // If chunk options are provided, use them; otherwise assume it matches target options
737
+ const chunkOpts = chunk.options ?? targetOptions;
738
+ const isTargetFormat = !chunk.options; // If omitted, we assume it's already target format
739
+
740
+ if (i === 0 && isTargetFormat) {
741
+ // Fast-path for the very first chunk: if it's already in the target format, append as-is
742
+ result = chunk.data;
743
+ currentState = getFinalState(result, targetOptions);
744
+ } else {
745
+ // Decode the chunk using its specific options
746
+ const decodedPoints = decode(chunk.data, chunkOpts);
747
+
748
+ // Re-encode into the target format, applying the offset from the current state
749
+ const reencodeOpts: EncodeOptions = {
750
+ ...targetOptions,
751
+ initialState: currentState
752
+ };
753
+
754
+ const encodedChunk = encode(decodedPoints, reencodeOpts);
755
+ result += encodedChunk;
756
+
757
+ currentState = getFinalState(encodedChunk, reencodeOpts);
758
+ }
759
+ }
760
+
761
+ return result;
762
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "monopolyline",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "An extended Google Polyline encoder/decoder supporting timestamps, accuracy, and sparse data strategies.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -20,4 +20,4 @@
20
20
  "typescript": "^5.0.0",
21
21
  "bun-types": "latest"
22
22
  }
23
- }
23
+ }