@opendata-ai/openchart-core 8.0.0-rc.2 → 8.0.0-rc.21
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 +490 -25
- package/dist/index.js +57 -19
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +1 -2
- package/schema/chart.schema.json +485 -35
- package/schema/table.schema.json +35 -1
- package/schema/vizspec.schema.json +1305 -120
- package/src/accessibility/__tests__/alt-text.test.ts +0 -110
- package/src/accessibility/__tests__/aria.test.ts +0 -125
- package/src/accessibility/alt-text.ts +0 -155
- package/src/accessibility/aria.ts +0 -75
- package/src/accessibility/index.ts +0 -6
- package/src/colors/__tests__/colorblind.test.ts +0 -75
- package/src/colors/__tests__/contrast.test.ts +0 -119
- package/src/colors/__tests__/palettes.test.ts +0 -74
- package/src/colors/colorblind.ts +0 -131
- package/src/colors/contrast.ts +0 -143
- package/src/colors/index.ts +0 -36
- package/src/colors/palettes.ts +0 -206
- package/src/helpers/__tests__/deep-merge.test.ts +0 -54
- package/src/helpers/__tests__/spec-builders.test.ts +0 -338
- package/src/helpers/deep-merge.ts +0 -29
- package/src/helpers/spec-builders.ts +0 -479
- package/src/index.ts +0 -215
- package/src/labels/__tests__/collision.test.ts +0 -310
- package/src/labels/collision.ts +0 -249
- package/src/labels/index.ts +0 -13
- package/src/layout/__tests__/chrome.test.ts +0 -208
- package/src/layout/__tests__/text-measure.test.ts +0 -57
- package/src/layout/chrome.ts +0 -470
- package/src/layout/index.ts +0 -18
- package/src/layout/text-measure.ts +0 -404
- package/src/layout/text-wrap.ts +0 -93
- package/src/locale/__tests__/format.test.ts +0 -606
- package/src/locale/format.ts +0 -459
- package/src/locale/index.ts +0 -21
- package/src/responsive/__tests__/breakpoints.test.ts +0 -146
- package/src/responsive/__tests__/metrics.test.ts +0 -52
- package/src/responsive/breakpoints.ts +0 -172
- package/src/responsive/index.ts +0 -53
- package/src/responsive/metrics.ts +0 -299
- package/src/schema/__tests__/generated-artifacts.test.ts +0 -42
- package/src/schema/__tests__/schema.test.ts +0 -179
- package/src/styles/__tests__/reduced-motion-selectors.test.ts +0 -28
- package/src/styles/animation.css +0 -199
- package/src/styles/base.css +0 -102
- package/src/styles/chrome.css +0 -174
- package/src/styles/dark.css +0 -44
- package/src/styles/graph.css +0 -135
- package/src/styles/index.css +0 -28
- package/src/styles/keyframes.css +0 -135
- package/src/styles/legend.css +0 -18
- package/src/styles/reduced-motion.css +0 -67
- package/src/styles/series-search.css +0 -153
- package/src/styles/sparkline.css +0 -16
- package/src/styles/table-animation.css +0 -93
- package/src/styles/table.css +0 -398
- package/src/styles/token-definitions.ts +0 -199
- package/src/styles/tokens.css +0 -156
- package/src/styles/tooltip.css +0 -126
- package/src/styles/you-draw-it.css +0 -126
- package/src/theme/__tests__/dark-mode.test.ts +0 -232
- package/src/theme/__tests__/defaults.test.ts +0 -73
- package/src/theme/__tests__/resolve.test.ts +0 -277
- package/src/theme/dark-mode.ts +0 -258
- package/src/theme/defaults.ts +0 -110
- package/src/theme/index.ts +0 -8
- package/src/theme/presets.ts +0 -64
- package/src/theme/resolve.ts +0 -312
- package/src/types/__tests__/encoding.test.ts +0 -368
- package/src/types/__tests__/gradient.test.ts +0 -95
- package/src/types/__tests__/spec.test.ts +0 -499
- package/src/types/encoding.ts +0 -280
- package/src/types/events.ts +0 -154
- package/src/types/index.ts +0 -287
- package/src/types/layout.ts +0 -1966
- package/src/types/schema-roots.ts +0 -32
- package/src/types/spec.ts +0 -2867
- package/src/types/table.ts +0 -108
- package/src/types/theme.ts +0 -209
|
@@ -1,338 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import type { EncodingChannel } from '../../types/spec';
|
|
3
|
-
import {
|
|
4
|
-
barChart,
|
|
5
|
-
columnChart,
|
|
6
|
-
dataTable,
|
|
7
|
-
inferFieldType,
|
|
8
|
-
lineChart,
|
|
9
|
-
pieChart,
|
|
10
|
-
scatterChart,
|
|
11
|
-
} from '../spec-builders';
|
|
12
|
-
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
// Test data
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
|
|
17
|
-
const timeSeriesData = [
|
|
18
|
-
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
19
|
-
{ date: '2021-01-01', value: 40, country: 'US' },
|
|
20
|
-
{ date: '2020-01-01', value: 15, country: 'UK' },
|
|
21
|
-
{ date: '2021-01-01', value: 35, country: 'UK' },
|
|
22
|
-
];
|
|
23
|
-
|
|
24
|
-
const categoricalData = [
|
|
25
|
-
{ name: 'Apples', count: 50, price: 1.2 },
|
|
26
|
-
{ name: 'Bananas', count: 30, price: 0.8 },
|
|
27
|
-
{ name: 'Oranges', count: 45, price: 1.5 },
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
const numericData = [
|
|
31
|
-
{ x: 1, y: 2, size: 10, group: 'A' },
|
|
32
|
-
{ x: 3, y: 4, size: 20, group: 'B' },
|
|
33
|
-
{ x: 5, y: 1, size: 15, group: 'A' },
|
|
34
|
-
];
|
|
35
|
-
|
|
36
|
-
const mixedData = [
|
|
37
|
-
{ id: 1, name: 'Alice', score: 95, joined: '2020-03-15' },
|
|
38
|
-
{ id: 2, name: 'Bob', score: 87, joined: '2021-07-22' },
|
|
39
|
-
{ id: 3, name: 'Carol', score: 92, joined: '2019-11-01' },
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
// inferFieldType
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
|
|
46
|
-
describe('inferFieldType', () => {
|
|
47
|
-
it('infers quantitative for number fields', () => {
|
|
48
|
-
expect(inferFieldType(categoricalData, 'count')).toBe('quantitative');
|
|
49
|
-
expect(inferFieldType(categoricalData, 'price')).toBe('quantitative');
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('infers temporal for ISO date strings', () => {
|
|
53
|
-
expect(inferFieldType(timeSeriesData, 'date')).toBe('temporal');
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('infers temporal for partial ISO dates (YYYY-MM format)', () => {
|
|
57
|
-
const data = [{ period: '2020-01' }, { period: '2020-02' }];
|
|
58
|
-
expect(inferFieldType(data, 'period')).toBe('temporal');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('infers temporal for year-only strings (YYYY format)', () => {
|
|
62
|
-
const data = [{ year: '2020' }, { year: '2021' }];
|
|
63
|
-
expect(inferFieldType(data, 'year')).toBe('temporal');
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('infers nominal for plain string fields', () => {
|
|
67
|
-
expect(inferFieldType(categoricalData, 'name')).toBe('nominal');
|
|
68
|
-
expect(inferFieldType(timeSeriesData, 'country')).toBe('nominal');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('infers nominal for mixed types', () => {
|
|
72
|
-
const data = [{ value: 10 }, { value: 'text' }];
|
|
73
|
-
expect(inferFieldType(data, 'value')).toBe('nominal');
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('handles null/undefined values gracefully', () => {
|
|
77
|
-
const data = [{ value: null }, { value: undefined }, { value: 42 }];
|
|
78
|
-
expect(inferFieldType(data, 'value')).toBe('quantitative');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('handles Date objects as temporal', () => {
|
|
82
|
-
const data = [{ date: new Date('2020-01-01') }, { date: new Date('2021-01-01') }];
|
|
83
|
-
expect(inferFieldType(data, 'date')).toBe('temporal');
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('samples at most 20 values', () => {
|
|
87
|
-
// Create 100 items, all numbers. Should still work without issue.
|
|
88
|
-
const data = Array.from({ length: 100 }, (_, i) => ({ v: i }));
|
|
89
|
-
expect(inferFieldType(data, 'v')).toBe('quantitative');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('returns nominal for empty data', () => {
|
|
93
|
-
expect(inferFieldType([], 'anything')).toBe('nominal');
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
// lineChart
|
|
99
|
-
// ---------------------------------------------------------------------------
|
|
100
|
-
|
|
101
|
-
describe('lineChart', () => {
|
|
102
|
-
it('creates a line chart spec with string field names', () => {
|
|
103
|
-
const spec = lineChart(timeSeriesData, 'date', 'value');
|
|
104
|
-
|
|
105
|
-
expect(spec.mark).toBe('line');
|
|
106
|
-
expect(spec.data).toBe(timeSeriesData);
|
|
107
|
-
expect(spec.encoding.x).toEqual({ field: 'date', type: 'temporal' });
|
|
108
|
-
expect(spec.encoding.y).toEqual({ field: 'value', type: 'quantitative' });
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('accepts full EncodingChannel objects', () => {
|
|
112
|
-
const xChannel: EncodingChannel = {
|
|
113
|
-
field: 'date',
|
|
114
|
-
type: 'temporal',
|
|
115
|
-
axis: { title: 'Year' },
|
|
116
|
-
};
|
|
117
|
-
const yChannel: EncodingChannel = {
|
|
118
|
-
field: 'value',
|
|
119
|
-
type: 'quantitative',
|
|
120
|
-
scale: { zero: true },
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const spec = lineChart(timeSeriesData, xChannel, yChannel);
|
|
124
|
-
|
|
125
|
-
expect(spec.encoding.x).toEqual(xChannel);
|
|
126
|
-
expect(spec.encoding.y).toEqual(yChannel);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('includes color encoding from options', () => {
|
|
130
|
-
const spec = lineChart(timeSeriesData, 'date', 'value', {
|
|
131
|
-
color: 'country',
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
expect(spec.encoding.color).toEqual({ field: 'country', type: 'nominal' });
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it('passes through chrome and annotations', () => {
|
|
138
|
-
const spec = lineChart(timeSeriesData, 'date', 'value', {
|
|
139
|
-
chrome: { title: 'GDP Growth' },
|
|
140
|
-
annotations: [{ type: 'refline', y: 0, label: 'Zero' }],
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
expect(spec.chrome).toEqual({ title: 'GDP Growth' });
|
|
144
|
-
expect(spec.annotations).toHaveLength(1);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('passes through theme and darkMode', () => {
|
|
148
|
-
const spec = lineChart(timeSeriesData, 'date', 'value', {
|
|
149
|
-
theme: { borderRadius: 8 },
|
|
150
|
-
darkMode: 'auto',
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
expect(spec.theme).toEqual({ borderRadius: 8 });
|
|
154
|
-
expect(spec.darkMode).toBe('auto');
|
|
155
|
-
});
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
// ---------------------------------------------------------------------------
|
|
159
|
-
// barChart
|
|
160
|
-
// ---------------------------------------------------------------------------
|
|
161
|
-
|
|
162
|
-
describe('barChart', () => {
|
|
163
|
-
it('maps category to y-axis and value to x-axis', () => {
|
|
164
|
-
const spec = barChart(categoricalData, 'name', 'count');
|
|
165
|
-
|
|
166
|
-
expect(spec.mark).toBe('bar');
|
|
167
|
-
// Bar chart convention: category on y, value on x
|
|
168
|
-
expect(spec.encoding.y).toEqual({ field: 'name', type: 'nominal' });
|
|
169
|
-
expect(spec.encoding.x).toEqual({ field: 'count', type: 'quantitative' });
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('accepts full channel objects', () => {
|
|
173
|
-
const catChannel: EncodingChannel = {
|
|
174
|
-
field: 'name',
|
|
175
|
-
type: 'ordinal',
|
|
176
|
-
axis: { title: 'Fruit' },
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
const spec = barChart(categoricalData, catChannel, 'count');
|
|
180
|
-
|
|
181
|
-
expect(spec.encoding.y).toEqual(catChannel);
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
// ---------------------------------------------------------------------------
|
|
186
|
-
// columnChart
|
|
187
|
-
// ---------------------------------------------------------------------------
|
|
188
|
-
|
|
189
|
-
describe('columnChart', () => {
|
|
190
|
-
it('creates a column chart spec with x and y', () => {
|
|
191
|
-
const spec = columnChart(categoricalData, 'name', 'count');
|
|
192
|
-
|
|
193
|
-
// columnChart now produces mark: 'bar' (vertical orientation inferred from encoding)
|
|
194
|
-
expect(typeof spec.mark).toBe('object');
|
|
195
|
-
expect((spec.mark as Record<string, unknown>).type).toBe('bar');
|
|
196
|
-
expect(spec.encoding.x).toEqual({ field: 'name', type: 'nominal' });
|
|
197
|
-
expect(spec.encoding.y).toEqual({ field: 'count', type: 'quantitative' });
|
|
198
|
-
});
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
// ---------------------------------------------------------------------------
|
|
202
|
-
// pieChart
|
|
203
|
-
// ---------------------------------------------------------------------------
|
|
204
|
-
|
|
205
|
-
describe('pieChart', () => {
|
|
206
|
-
it('maps category to color channel and value to theta', () => {
|
|
207
|
-
const spec = pieChart(categoricalData, 'name', 'count');
|
|
208
|
-
|
|
209
|
-
expect(spec.mark).toBe('arc');
|
|
210
|
-
// Arc (pie) convention: value on theta (canonical), category on color, no x
|
|
211
|
-
expect(spec.encoding.theta).toEqual({ field: 'count', type: 'quantitative' });
|
|
212
|
-
expect(spec.encoding.color).toEqual({ field: 'name', type: 'nominal' });
|
|
213
|
-
expect(spec.encoding.x).toBeUndefined();
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it('includes size encoding when specified', () => {
|
|
217
|
-
const spec = pieChart(numericData, 'group', 'y', { size: 'size' });
|
|
218
|
-
|
|
219
|
-
expect(spec.encoding.size).toEqual({ field: 'size', type: 'quantitative' });
|
|
220
|
-
});
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
// ---------------------------------------------------------------------------
|
|
224
|
-
// scatterChart
|
|
225
|
-
// ---------------------------------------------------------------------------
|
|
226
|
-
|
|
227
|
-
describe('scatterChart', () => {
|
|
228
|
-
it('creates a scatter chart with both axes quantitative', () => {
|
|
229
|
-
const spec = scatterChart(numericData, 'x', 'y');
|
|
230
|
-
|
|
231
|
-
expect(spec.mark).toBe('point');
|
|
232
|
-
expect(spec.encoding.x).toEqual({ field: 'x', type: 'quantitative' });
|
|
233
|
-
expect(spec.encoding.y).toEqual({ field: 'y', type: 'quantitative' });
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it('supports color and size encoding', () => {
|
|
237
|
-
const spec = scatterChart(numericData, 'x', 'y', {
|
|
238
|
-
color: 'group',
|
|
239
|
-
size: 'size',
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
expect(spec.encoding.color).toEqual({ field: 'group', type: 'nominal' });
|
|
243
|
-
expect(spec.encoding.size).toEqual({ field: 'size', type: 'quantitative' });
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
// ---------------------------------------------------------------------------
|
|
248
|
-
// dataTable
|
|
249
|
-
// ---------------------------------------------------------------------------
|
|
250
|
-
|
|
251
|
-
describe('dataTable', () => {
|
|
252
|
-
it('auto-generates columns from data keys', () => {
|
|
253
|
-
const spec = dataTable(categoricalData);
|
|
254
|
-
|
|
255
|
-
expect(spec.type).toBe('table');
|
|
256
|
-
expect(spec.data).toBe(categoricalData);
|
|
257
|
-
expect(spec.columns).toHaveLength(3);
|
|
258
|
-
expect(spec.columns[0]).toEqual({ key: 'name', label: 'name', align: 'left' });
|
|
259
|
-
expect(spec.columns[1]).toEqual({ key: 'count', label: 'count', align: 'right' });
|
|
260
|
-
expect(spec.columns[2]).toEqual({ key: 'price', label: 'price', align: 'right' });
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it('uses provided columns instead of auto-generating', () => {
|
|
264
|
-
const columns = [
|
|
265
|
-
{ key: 'name', label: 'Fruit Name' },
|
|
266
|
-
{ key: 'count', label: 'Quantity', format: ',.0f' },
|
|
267
|
-
];
|
|
268
|
-
const spec = dataTable(categoricalData, { columns });
|
|
269
|
-
|
|
270
|
-
expect(spec.columns).toBe(columns);
|
|
271
|
-
expect(spec.columns).toHaveLength(2);
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it('right-aligns numeric columns in auto-generated config', () => {
|
|
275
|
-
const spec = dataTable(mixedData);
|
|
276
|
-
|
|
277
|
-
const idCol = spec.columns.find((c) => c.key === 'id');
|
|
278
|
-
const scoreCol = spec.columns.find((c) => c.key === 'score');
|
|
279
|
-
const nameCol = spec.columns.find((c) => c.key === 'name');
|
|
280
|
-
|
|
281
|
-
expect(idCol?.align).toBe('right');
|
|
282
|
-
expect(scoreCol?.align).toBe('right');
|
|
283
|
-
expect(nameCol?.align).toBe('left');
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it('passes through all table options', () => {
|
|
287
|
-
const spec = dataTable(categoricalData, {
|
|
288
|
-
rowKey: 'name',
|
|
289
|
-
chrome: { title: 'Fruit Data' },
|
|
290
|
-
search: true,
|
|
291
|
-
pagination: { pageSize: 10 },
|
|
292
|
-
stickyFirstColumn: true,
|
|
293
|
-
compact: true,
|
|
294
|
-
darkMode: 'auto',
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
expect(spec.rowKey).toBe('name');
|
|
298
|
-
expect(spec.chrome).toEqual({ title: 'Fruit Data' });
|
|
299
|
-
expect(spec.search).toBe(true);
|
|
300
|
-
expect(spec.pagination).toEqual({ pageSize: 10 });
|
|
301
|
-
expect(spec.stickyFirstColumn).toBe(true);
|
|
302
|
-
expect(spec.compact).toBe(true);
|
|
303
|
-
expect(spec.darkMode).toBe('auto');
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it('returns empty columns for empty data', () => {
|
|
307
|
-
const spec = dataTable([]);
|
|
308
|
-
|
|
309
|
-
expect(spec.columns).toEqual([]);
|
|
310
|
-
});
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
// ---------------------------------------------------------------------------
|
|
314
|
-
// Mixed field ref (string vs object) across builders
|
|
315
|
-
// ---------------------------------------------------------------------------
|
|
316
|
-
|
|
317
|
-
describe('mixed FieldRef usage', () => {
|
|
318
|
-
it('allows mixing string and object field refs', () => {
|
|
319
|
-
const yChannel: EncodingChannel = {
|
|
320
|
-
field: 'value',
|
|
321
|
-
type: 'quantitative',
|
|
322
|
-
aggregate: 'mean',
|
|
323
|
-
axis: { title: 'Average Value', format: ',.1f' },
|
|
324
|
-
};
|
|
325
|
-
|
|
326
|
-
const spec = lineChart(timeSeriesData, 'date', yChannel, {
|
|
327
|
-
color: { field: 'country', type: 'nominal' },
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
// x was a string, so type was inferred
|
|
331
|
-
expect(spec.encoding.x?.type).toBe('temporal');
|
|
332
|
-
// y was a full object, so it's passed through
|
|
333
|
-
expect(spec.encoding.y?.aggregate).toBe('mean');
|
|
334
|
-
expect(spec.encoding.y?.axis?.title).toBe('Average Value');
|
|
335
|
-
// color was a full object
|
|
336
|
-
expect(spec.encoding.color?.type).toBe('nominal');
|
|
337
|
-
});
|
|
338
|
-
});
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deep merge for applying partial patches onto a spec-shaped object.
|
|
3
|
-
*
|
|
4
|
-
* Used by the scrollytelling story layer (`vanilla/story`) to apply
|
|
5
|
-
* cumulative deep-partial spec patches onto a base spec. Arrays REPLACE
|
|
6
|
-
* rather than merge element-by-element: predictable behavior for things
|
|
7
|
-
* like `annotations` or `data`, where index-wise merging would silently
|
|
8
|
-
* produce nonsense. Plain objects merge key-by-key, recursively.
|
|
9
|
-
*/
|
|
10
|
-
// biome-ignore lint/suspicious/noExplicitAny: recursive object merge requires dynamic typing
|
|
11
|
-
export function deepMergeSpec(target: any, source: any): any {
|
|
12
|
-
if (source === undefined) return target;
|
|
13
|
-
if (source === null) return source;
|
|
14
|
-
if (
|
|
15
|
-
typeof source !== 'object' ||
|
|
16
|
-
Array.isArray(source) ||
|
|
17
|
-
typeof target !== 'object' ||
|
|
18
|
-
target === null ||
|
|
19
|
-
Array.isArray(target)
|
|
20
|
-
) {
|
|
21
|
-
return source;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const result = { ...target };
|
|
25
|
-
for (const key of Object.keys(source)) {
|
|
26
|
-
result[key] = deepMergeSpec(target[key], source[key]);
|
|
27
|
-
}
|
|
28
|
-
return result;
|
|
29
|
-
}
|