@opendata-ai/openchart-engine 8.0.0-rc.1 → 8.0.0-rc.10
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/dist/index.d.ts +2 -8
- package/dist/index.js +793 -91
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +62 -0
- package/src/__tests__/annotation-behavior.test.ts +131 -0
- package/src/__tests__/facet.test.ts +144 -0
- package/src/annotations/geometry.ts +7 -1
- package/src/annotations/resolve-text.ts +6 -0
- package/src/compile/layer.ts +4 -0
- package/src/compile/size-scale.ts +2 -0
- package/src/compile/spec-sugar.ts +10 -1
- package/src/compile.ts +106 -8
- package/src/compiler/__tests__/keys.test.ts +3 -9
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +84 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +162 -0
- package/src/compiler/__tests__/validate.test.ts +102 -0
- package/src/compiler/keys.ts +5 -4
- package/src/compiler/normalize.ts +1 -0
- package/src/compiler/validate.ts +326 -43
- package/src/geo/__tests__/compile-map.test.ts +507 -0
- package/src/geo/compile-map.ts +555 -35
- package/src/geo/projections.ts +21 -1
- package/src/geo/types.ts +2 -0
- package/src/layout/axes.ts +5 -4
- package/src/legend/compute.ts +4 -3
|
@@ -62,11 +62,11 @@ describe('dedupeKeys', () => {
|
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
it('suffixes duplicates with occurrence index', () => {
|
|
65
|
-
expect(dedupeKeys(['a', 'a', 'a'])).toEqual(['a
|
|
65
|
+
expect(dedupeKeys(['a', 'a', 'a'])).toEqual(['a⸱0', 'a⸱1', 'a⸱2']);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
it('only suffixes keys that appear more than once', () => {
|
|
69
|
-
expect(dedupeKeys(['a', 'b', 'a'])).toEqual(['a
|
|
69
|
+
expect(dedupeKeys(['a', 'b', 'a'])).toEqual(['a⸱0', 'b', 'a⸱1']);
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
it('handles empty array', () => {
|
|
@@ -78,12 +78,6 @@ describe('dedupeKeys', () => {
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
it('handles multiple duplicate groups', () => {
|
|
81
|
-
expect(dedupeKeys(['x', 'y', 'x', 'y', 'z'])).toEqual([
|
|
82
|
-
'x\x010',
|
|
83
|
-
'y\x010',
|
|
84
|
-
'x\x011',
|
|
85
|
-
'y\x011',
|
|
86
|
-
'z',
|
|
87
|
-
]);
|
|
81
|
+
expect(dedupeKeys(['x', 'y', 'x', 'y', 'z'])).toEqual(['x⸱0', 'y⸱0', 'x⸱1', 'y⸱1', 'z']);
|
|
88
82
|
});
|
|
89
83
|
});
|
|
@@ -66,6 +66,90 @@ describe('did-you-mean field suggestions', () => {
|
|
|
66
66
|
expect(suggestion).toContain('Did you mean "quarter"?');
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
it('suggests the nearest column for a misspelled sankey encoding field', () => {
|
|
70
|
+
const result = validateSpec({
|
|
71
|
+
type: 'sankey',
|
|
72
|
+
data: [{ source: 'A', target: 'B', value: 10 }],
|
|
73
|
+
encoding: {
|
|
74
|
+
source: { field: 'sorce', type: 'nominal' }, // typo of "source"
|
|
75
|
+
target: { field: 'target', type: 'nominal' },
|
|
76
|
+
value: { field: 'value', type: 'quantitative' },
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
expect(result.valid).toBe(false);
|
|
80
|
+
const suggestion = suggestionFor(result, 'encoding.source.field');
|
|
81
|
+
expect(suggestion).toContain('Did you mean "source"?');
|
|
82
|
+
expect(suggestion).toContain('target');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('suggests the nearest column for a misspelled tilemap encoding field', () => {
|
|
86
|
+
const result = validateSpec({
|
|
87
|
+
type: 'tilemap',
|
|
88
|
+
data: [{ state: 'CA', value: 12000 }],
|
|
89
|
+
encoding: {
|
|
90
|
+
state: { field: 'state', type: 'nominal' },
|
|
91
|
+
value: { field: 'vlaue', type: 'quantitative' }, // typo of "value"
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
expect(result.valid).toBe(false);
|
|
95
|
+
const suggestion = suggestionFor(result, 'encoding.value.field');
|
|
96
|
+
expect(suggestion).toContain('Did you mean "value"?');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('suggests the nearest column for a misspelled barlist encoding field', () => {
|
|
100
|
+
const result = validateSpec({
|
|
101
|
+
type: 'barlist',
|
|
102
|
+
data: [{ label: 'A', value: 42 }],
|
|
103
|
+
encoding: {
|
|
104
|
+
label: { field: 'lable', type: 'nominal' }, // typo of "label"
|
|
105
|
+
value: { field: 'value', type: 'quantitative' },
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
expect(result.valid).toBe(false);
|
|
109
|
+
const suggestion = suggestionFor(result, 'encoding.label.field');
|
|
110
|
+
expect(suggestion).toContain('Did you mean "label"?');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('suggests the nearest column for a misspelled optional barlist channel field', () => {
|
|
114
|
+
const result = validateSpec({
|
|
115
|
+
type: 'barlist',
|
|
116
|
+
data: [{ label: 'A', value: 42, group: 'x' }],
|
|
117
|
+
encoding: {
|
|
118
|
+
label: { field: 'label', type: 'nominal' },
|
|
119
|
+
value: { field: 'value', type: 'quantitative' },
|
|
120
|
+
color: { field: 'gruop', type: 'nominal' }, // typo of "group"
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
expect(result.valid).toBe(false);
|
|
124
|
+
const suggestion = suggestionFor(result, 'encoding.color.field');
|
|
125
|
+
expect(suggestion).toContain('Did you mean "group"?');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('suggests the nearest node field for a misspelled graph encoding field', () => {
|
|
129
|
+
const result = validateSpec({
|
|
130
|
+
type: 'graph',
|
|
131
|
+
nodes: [{ id: 'a', category: 'x' }],
|
|
132
|
+
edges: [],
|
|
133
|
+
encoding: {
|
|
134
|
+
nodeColor: { field: 'catgory', type: 'nominal' }, // typo of "category"
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
expect(result.valid).toBe(false);
|
|
138
|
+
const suggestion = suggestionFor(result, 'encoding.nodeColor.field');
|
|
139
|
+
expect(suggestion).toContain('Did you mean "category"?');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('suggests the nearest node id for a misspelled graph edge endpoint', () => {
|
|
143
|
+
const result = validateSpec({
|
|
144
|
+
type: 'graph',
|
|
145
|
+
nodes: [{ id: 'alpha' }, { id: 'beta' }],
|
|
146
|
+
edges: [{ source: 'alpha', target: 'betta' }], // typo of "beta"
|
|
147
|
+
});
|
|
148
|
+
expect(result.valid).toBe(false);
|
|
149
|
+
const suggestion = suggestionFor(result, 'edges[0].target');
|
|
150
|
+
expect(suggestion).toContain('Did you mean "beta"?');
|
|
151
|
+
});
|
|
152
|
+
|
|
69
153
|
it('does not match on a short unrelated field where the edit distance exceeds the threshold', () => {
|
|
70
154
|
const shortData = [
|
|
71
155
|
{ a: 1, b: 2 },
|
|
@@ -87,3 +87,165 @@ describe('near-miss validation messages', () => {
|
|
|
87
87
|
expect(result.errors.filter((e) => e.path === 'encoding.color.scale.scheme')).toHaveLength(0);
|
|
88
88
|
});
|
|
89
89
|
});
|
|
90
|
+
|
|
91
|
+
describe('scale.scheme on non-chart specs', () => {
|
|
92
|
+
it('rejects any scheme on a sankey color channel as dead config', () => {
|
|
93
|
+
// Sankey never reads scale.scheme (node colors cycle theme.colors.categorical),
|
|
94
|
+
// so even a known name like "blues" must not be silently accepted.
|
|
95
|
+
for (const scheme of ['viridis', 'blues']) {
|
|
96
|
+
const result = validateSpec({
|
|
97
|
+
type: 'sankey',
|
|
98
|
+
data: [{ source: 'A', target: 'B', value: 10 }],
|
|
99
|
+
encoding: {
|
|
100
|
+
source: { field: 'source', type: 'nominal' },
|
|
101
|
+
target: { field: 'target', type: 'nominal' },
|
|
102
|
+
value: { field: 'value', type: 'quantitative' },
|
|
103
|
+
color: { field: 'source', type: 'nominal', scale: { scheme } },
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
expect(result.valid).toBe(false);
|
|
107
|
+
const error = result.errors.find((e) => e.path === 'encoding.color.scale.scheme');
|
|
108
|
+
expect(error?.code).toBe('INVALID_VALUE');
|
|
109
|
+
expect(error?.message).toContain('has no effect');
|
|
110
|
+
expect(error?.suggestion).toContain('theme.colors.categorical');
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('rejects any scheme on a tilemap value channel, pointing at the palette prop', () => {
|
|
115
|
+
const result = validateSpec({
|
|
116
|
+
type: 'tilemap',
|
|
117
|
+
data: [{ state: 'CA', value: 12000 }],
|
|
118
|
+
encoding: {
|
|
119
|
+
state: { field: 'state', type: 'nominal' },
|
|
120
|
+
value: { field: 'value', type: 'quantitative', scale: { scheme: 'viridis' } },
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
expect(result.valid).toBe(false);
|
|
124
|
+
const error = result.errors.find((e) => e.path === 'encoding.value.scale.scheme');
|
|
125
|
+
expect(error?.code).toBe('INVALID_VALUE');
|
|
126
|
+
expect(error?.message).toContain('has no effect');
|
|
127
|
+
expect(error?.suggestion).toContain('palette: "green"');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('rejects any scheme on a barlist color channel as dead config', () => {
|
|
131
|
+
const result = validateSpec({
|
|
132
|
+
type: 'barlist',
|
|
133
|
+
data: [{ label: 'A', value: 42, group: 'x' }],
|
|
134
|
+
encoding: {
|
|
135
|
+
label: { field: 'label', type: 'nominal' },
|
|
136
|
+
value: { field: 'value', type: 'quantitative' },
|
|
137
|
+
color: { field: 'group', type: 'nominal', scale: { scheme: 'viridis' } },
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
expect(result.valid).toBe(false);
|
|
141
|
+
const error = result.errors.find((e) => e.path === 'encoding.color.scale.scheme');
|
|
142
|
+
expect(error?.code).toBe('INVALID_VALUE');
|
|
143
|
+
expect(error?.message).toContain('has no effect');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('rejects any scheme on a graph nodeColor channel as dead config', () => {
|
|
147
|
+
// Graph never reads scale.scheme (encoding.ts consumes only
|
|
148
|
+
// scale.range/scale.domain), so even a known name must not pass.
|
|
149
|
+
for (const scheme of ['viridis', 'blues']) {
|
|
150
|
+
const result = validateSpec({
|
|
151
|
+
type: 'graph',
|
|
152
|
+
nodes: [{ id: 'a', category: 'x' }],
|
|
153
|
+
edges: [],
|
|
154
|
+
encoding: {
|
|
155
|
+
nodeColor: { field: 'category', type: 'nominal', scale: { scheme } },
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
expect(result.valid).toBe(false);
|
|
159
|
+
const error = result.errors.find((e) => e.path === 'encoding.nodeColor.scale.scheme');
|
|
160
|
+
expect(error?.code).toBe('INVALID_VALUE');
|
|
161
|
+
expect(error?.message).toContain('has no effect');
|
|
162
|
+
expect(error?.suggestion).toContain('scale.range');
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('scale.scheme on map specs', () => {
|
|
168
|
+
// Minimal valid TopoJSON topology with one feature
|
|
169
|
+
const topo = {
|
|
170
|
+
type: 'Topology',
|
|
171
|
+
objects: {
|
|
172
|
+
things: {
|
|
173
|
+
type: 'GeometryCollection',
|
|
174
|
+
geometries: [{ type: 'Polygon', arcs: [[0]], id: '01' }],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
arcs: [
|
|
178
|
+
[
|
|
179
|
+
[0, 0],
|
|
180
|
+
[0, 1],
|
|
181
|
+
[1, 0],
|
|
182
|
+
[-1, -1],
|
|
183
|
+
],
|
|
184
|
+
],
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
it('rejects a scheme the map palette set does not support', () => {
|
|
188
|
+
const result = validateSpec({
|
|
189
|
+
type: 'map',
|
|
190
|
+
geo: { features: topo },
|
|
191
|
+
data: [{ id: '01', value: 5 }],
|
|
192
|
+
encoding: {
|
|
193
|
+
key: { field: 'id' },
|
|
194
|
+
color: { field: 'value', type: 'quantitative', scale: { scheme: 'viridis' } },
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
expect(result.valid).toBe(false);
|
|
198
|
+
const error = result.errors.find((e) => e.path === 'encoding.color.scale.scheme');
|
|
199
|
+
expect(error?.code).toBe('INVALID_VALUE');
|
|
200
|
+
expect(error?.message).toContain('"viridis" is not a supported map palette');
|
|
201
|
+
expect(error?.suggestion).toContain('green');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('accepts a supported sequential palette on a map color channel', () => {
|
|
205
|
+
const result = validateSpec({
|
|
206
|
+
type: 'map',
|
|
207
|
+
geo: { features: topo },
|
|
208
|
+
data: [{ id: '01', value: 5 }],
|
|
209
|
+
encoding: {
|
|
210
|
+
key: { field: 'id' },
|
|
211
|
+
color: { field: 'value', type: 'quantitative', scale: { scheme: 'green' } },
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
expect(result.errors.filter((e) => e.path === 'encoding.color.scale.scheme')).toHaveLength(0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('rejects any scheme on a categorical map color channel as dead config', () => {
|
|
218
|
+
const result = validateSpec({
|
|
219
|
+
type: 'map',
|
|
220
|
+
geo: { features: topo },
|
|
221
|
+
data: [{ id: '01', region: 'south' }],
|
|
222
|
+
encoding: {
|
|
223
|
+
key: { field: 'id' },
|
|
224
|
+
color: { field: 'region', type: 'nominal', scale: { scheme: 'green' } },
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
expect(result.valid).toBe(false);
|
|
228
|
+
const error = result.errors.find((e) => e.path === 'encoding.color.scale.scheme');
|
|
229
|
+
expect(error?.code).toBe('INVALID_VALUE');
|
|
230
|
+
expect(error?.message).toContain('has no effect');
|
|
231
|
+
expect(error?.suggestion).toContain('scale.range');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('rejects an unsupported scheme on the points layer color channel', () => {
|
|
235
|
+
const result = validateSpec({
|
|
236
|
+
type: 'map',
|
|
237
|
+
geo: { features: topo },
|
|
238
|
+
data: [],
|
|
239
|
+
encoding: { key: { field: 'id' } },
|
|
240
|
+
points: {
|
|
241
|
+
data: [{ lat: 30, lon: -97, score: 1 }],
|
|
242
|
+
latitude: { field: 'lat' },
|
|
243
|
+
longitude: { field: 'lon' },
|
|
244
|
+
color: { field: 'score', type: 'quantitative', scale: { scheme: 'magma' } },
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
const error = result.errors.find((e) => e.path === 'points.color.scale.scheme');
|
|
248
|
+
expect(error?.code).toBe('INVALID_VALUE');
|
|
249
|
+
expect(error?.message).toContain('"magma" is not a supported map palette');
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -626,4 +626,106 @@ describe('validateSpec', () => {
|
|
|
626
626
|
expect(result.errors.find((e) => e.code === 'DATA_FIELD_MISSING')).toBeUndefined();
|
|
627
627
|
});
|
|
628
628
|
});
|
|
629
|
+
|
|
630
|
+
describe('row/column facet validation', () => {
|
|
631
|
+
it('validates row channel field and type', () => {
|
|
632
|
+
const result = validateSpec({
|
|
633
|
+
mark: 'bar',
|
|
634
|
+
data: validLineData,
|
|
635
|
+
encoding: {
|
|
636
|
+
x: { field: 'value', type: 'quantitative' },
|
|
637
|
+
y: { field: 'country', type: 'nominal' },
|
|
638
|
+
row: { field: 'nonexistent', type: 'nominal' },
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
expect(result.errors.some((e) => e.path === 'encoding.row.field')).toBe(true);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('rejects row with invalid type', () => {
|
|
645
|
+
const result = validateSpec({
|
|
646
|
+
mark: 'bar',
|
|
647
|
+
data: validLineData,
|
|
648
|
+
encoding: {
|
|
649
|
+
x: { field: 'value', type: 'quantitative' },
|
|
650
|
+
y: { field: 'country', type: 'nominal' },
|
|
651
|
+
row: { field: 'country', type: 'quantitative' as 'nominal' },
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
expect(result.errors.some((e) => e.path === 'encoding.row.type')).toBe(true);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it('rejects row + facet together', () => {
|
|
658
|
+
const result = validateSpec({
|
|
659
|
+
mark: 'bar',
|
|
660
|
+
data: validLineData,
|
|
661
|
+
encoding: {
|
|
662
|
+
x: { field: 'value', type: 'quantitative' },
|
|
663
|
+
y: { field: 'country', type: 'nominal' },
|
|
664
|
+
row: { field: 'country', type: 'nominal' },
|
|
665
|
+
facet: { field: 'country', type: 'nominal' },
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
expect(result.errors.some((e) => e.message.includes('encoding.row and encoding.facet'))).toBe(
|
|
669
|
+
true,
|
|
670
|
+
);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('rejects row + column together', () => {
|
|
674
|
+
const result = validateSpec({
|
|
675
|
+
mark: 'bar',
|
|
676
|
+
data: validLineData,
|
|
677
|
+
encoding: {
|
|
678
|
+
x: { field: 'value', type: 'quantitative' },
|
|
679
|
+
y: { field: 'country', type: 'nominal' },
|
|
680
|
+
row: { field: 'country', type: 'nominal' },
|
|
681
|
+
column: { field: 'date', type: 'ordinal' },
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
expect(
|
|
685
|
+
result.errors.some((e) => e.message.includes('encoding.row and encoding.column')),
|
|
686
|
+
).toBe(true);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it('accepts valid row channel', () => {
|
|
690
|
+
const result = validateSpec({
|
|
691
|
+
mark: 'bar',
|
|
692
|
+
data: validLineData,
|
|
693
|
+
encoding: {
|
|
694
|
+
x: { field: 'value', type: 'quantitative' },
|
|
695
|
+
y: { field: 'country', type: 'nominal' },
|
|
696
|
+
row: { field: 'country', type: 'nominal' },
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
expect(result.errors.filter((e) => e.path?.startsWith('encoding.row'))).toHaveLength(0);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it('rejects column + facet together', () => {
|
|
703
|
+
const result = validateSpec({
|
|
704
|
+
mark: 'bar',
|
|
705
|
+
data: validLineData,
|
|
706
|
+
encoding: {
|
|
707
|
+
x: { field: 'value', type: 'quantitative' },
|
|
708
|
+
y: { field: 'country', type: 'nominal' },
|
|
709
|
+
column: { field: 'country', type: 'nominal' },
|
|
710
|
+
facet: { field: 'country', type: 'nominal' },
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
expect(
|
|
714
|
+
result.errors.some((e) => e.message.includes('encoding.column and encoding.facet')),
|
|
715
|
+
).toBe(true);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('accepts valid column channel', () => {
|
|
719
|
+
const result = validateSpec({
|
|
720
|
+
mark: 'bar',
|
|
721
|
+
data: validLineData,
|
|
722
|
+
encoding: {
|
|
723
|
+
x: { field: 'country', type: 'nominal' },
|
|
724
|
+
y: { field: 'value', type: 'quantitative' },
|
|
725
|
+
column: { field: 'country', type: 'nominal' },
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
expect(result.errors.filter((e) => e.path?.startsWith('encoding.column'))).toHaveLength(0);
|
|
729
|
+
});
|
|
730
|
+
});
|
|
629
731
|
});
|
package/src/compiler/keys.ts
CHANGED
|
@@ -28,22 +28,23 @@ export function serializeKeyValue(v: unknown): string {
|
|
|
28
28
|
*
|
|
29
29
|
* Only keys that appear more than once get suffixed. Unique keys pass
|
|
30
30
|
* through unchanged so keys remain human-readable when possible.
|
|
31
|
-
* Uses
|
|
31
|
+
* Uses U+2E31 (word separator middle dot) as separator - XML-safe and
|
|
32
|
+
* unlikely to appear in real data values.
|
|
32
33
|
*/
|
|
34
|
+
const KEY_DEDUP_SEP = '⸱';
|
|
35
|
+
|
|
33
36
|
export function dedupeKeys(keys: string[]): string[] {
|
|
34
|
-
// Count occurrences
|
|
35
37
|
const counts = new Map<string, number>();
|
|
36
38
|
for (const k of keys) {
|
|
37
39
|
counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
// Assign suffixes only to duplicates
|
|
41
42
|
const seen = new Map<string, number>();
|
|
42
43
|
const result: string[] = [];
|
|
43
44
|
for (const k of keys) {
|
|
44
45
|
if (counts.get(k)! > 1) {
|
|
45
46
|
const idx = seen.get(k) ?? 0;
|
|
46
|
-
result.push(`${k}
|
|
47
|
+
result.push(`${k}${KEY_DEDUP_SEP}${idx}`);
|
|
47
48
|
seen.set(k, idx + 1);
|
|
48
49
|
} else {
|
|
49
50
|
result.push(k);
|