@rokkit/data 1.0.0-next.94

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/src/dataset.js ADDED
@@ -0,0 +1,442 @@
1
+ import { equals, identity, pick, omit, clone } from 'ramda'
2
+ import { descending } from 'd3-array'
3
+ import { pickAllowedConfig, defaultConfig, includeAll } from './constants'
4
+ import { deriveSortableColumn } from './infer'
5
+ import { groupDataByKeys, fillAlignedData, getAlignGenerator, aggregateData } from './rollup'
6
+ /**
7
+ * Dataset is a collection of data with a set of operations that can be performed on it.
8
+ * @param {Array} data - The data to be stored in the dataset.
9
+ * @param {Object} options - The configuration options for the dataset.
10
+ * @returns {Object} - An object with a set of operations that can be performed on the dataset.
11
+ */
12
+ // eslint-disable-next-line max-lines-per-function
13
+ export function dataset(data, options = {}) {
14
+ const config = { ...clone(defaultConfig), ...options }
15
+
16
+ const actions = {
17
+ // configuration
18
+ override: (props) => dataset(data, { ...config, ...pickAllowedConfig(props) }),
19
+ where: (condition) => dataset(data, where(config, condition)),
20
+ groupBy: (...fields) => dataset(data, groupBy(config, ...fields)),
21
+ alignBy: (...fields) => dataset(data, alignBy(config, ...fields)),
22
+ using: (template) => dataset(data, usingTemplate(config, template)),
23
+ summarize: (from, fields) => dataset(data, summarize(config, from, fields)),
24
+ // alter keys
25
+ rename: (how) => dataset(renameKeys(data, how)),
26
+ drop: (...fields) => dataset(dropKeys(data, ...fields)),
27
+ // alter rows
28
+ sortBy: (...fields) => dataset(sortDataBy(data, ...fields)),
29
+ delete: () => dataset(deleteRows(data, config.filter || includeAll)),
30
+ update: (value) => dataset(updateRows(data, config.filter || identity, value)),
31
+ fillNA: (value) => dataset(fillNA(data, value)),
32
+ // transform data
33
+ apply: (callback) => dataset(data.map(callback)),
34
+ rollup: () => dataset(rollup(data, config)),
35
+ select: (...cols) => selectKeys(data, config, ...cols),
36
+ // set operations
37
+ union: (other) => dataset(data.concat(other)),
38
+ minus: (other) => dataset(data.filter((d) => !other.find((x) => equals(x, d)))),
39
+ intersect: (other) => dataset(data.filter((d) => other.find((x) => equals(x, d)))),
40
+ // joins
41
+ innerJoin: (other, condition) => dataset(innerJoin(data, other.select(), condition)),
42
+ leftJoin: (other, condition) => dataset(leftJoin(data, other.select(), condition)),
43
+ rightJoin: (other, condition) => dataset(rightJoin(data, other.select(), condition)),
44
+ fullJoin: (other, condition) => dataset(fullJoin(data, other.select(), condition)),
45
+ crossJoin: (other) => dataset(crossJoin(data, other.select())),
46
+ semiJoin: (other, condition) => dataset(semiJoin(data, other.select(), condition)),
47
+ antiJoin: (other, condition) => dataset(antiJoin(data, other.select(), condition)),
48
+ nestedJoin: (other, condition) =>
49
+ dataset(nestedJoin(data, other.select(), condition, config.children))
50
+ }
51
+
52
+ return actions
53
+ }
54
+
55
+ /**
56
+ * Adds a filter to the dataset using the provided condition. This filter is applied
57
+ * in subsequent operations like select, delete, and update.
58
+ *
59
+ * @param {Object} config - The configuration object to be updated.
60
+ * @param {Function} condition - The condition function to apply.
61
+ * @returns {Object} - The updated configuration
62
+ */
63
+ function where(config, condition) {
64
+ config.filter = condition
65
+ return config
66
+ }
67
+ /**
68
+ * Groups the dataset by the specified columns.
69
+ * @param {Object} config - The configuration object to be updated.
70
+ * @param {Object} actions - The actions object for method chaining.
71
+ * @param {...string} fields - The columns to group by.
72
+ * @returns {Object} - The updated configurations.
73
+ */
74
+ function groupBy(config, ...fields) {
75
+ config.group_by = fields
76
+ return config
77
+ }
78
+
79
+ /**
80
+ * Aligns the columns of the dataset using the provided fields.
81
+ *
82
+ * @param {Object} config - The configuration object to be updated.
83
+ * @param {...string} fields - The fields to align.
84
+ * @returns {Object} - The updated configuration.
85
+ */
86
+ function alignBy(config, ...fields) {
87
+ config.align_by = fields
88
+ return config
89
+ }
90
+ /**
91
+ * Sets the template for adding empty rows in the dataset.
92
+ * @param {Object} config - The configuration object to be updated.
93
+ * @param {Object} template - The template to use for adding empty rows.
94
+ * @returns {Object} - The updated configuration.
95
+ */
96
+ function usingTemplate(config, template) {
97
+ config.template = template
98
+ return config
99
+ }
100
+
101
+ /**
102
+ * Adds a summary field to the dataset.
103
+ *
104
+ * @param {Object} config - The configuration object to be updated.
105
+ * @param {string|Array<string>|Function} from - The field or function to fetch data for summary
106
+ * @param {string|Object<string:Function>} using - The target field & formula to use for summarizing.
107
+ * @returns {Object} - The updated configuration.
108
+ */
109
+ function summarize(config, from, using) {
110
+ const mapper = typeof from === 'function' ? from : Array.isArray(from) ? pick(from) : pick([from])
111
+ const reducers = []
112
+
113
+ if (typeof using === 'string') reducers.push({ field: using, formula: identity })
114
+ if (typeof using === 'object')
115
+ Object.entries(using).forEach(([field, formula]) => reducers.push({ field, formula }))
116
+
117
+ config.summaries.push({ mapper, reducers })
118
+
119
+ return config
120
+ }
121
+
122
+ /**
123
+ * Joins two datasets together based on a condition. Result includes all rows from the first
124
+ * dataset and matching rows from the second dataset. In case of multiple matches, all
125
+ * combinations are returned. When combining the rows, the columns from the first dataset take
126
+ * precedence.
127
+ *
128
+ * inner: only the rows that have a match in both datasets.
129
+ * outer: all rows from the first dataset and matching rows from the second dataset.
130
+ *
131
+ * @param {Array} first - The first dataset to join.
132
+ * @param {Array} second - The second dataset to join.
133
+ * @param {Function} condition - The condition to join the datasets on.
134
+ * @returns {Object} - An object with the inner and outer join result.
135
+ */
136
+ function joinData(first, second, condition) {
137
+ let inner = []
138
+ const outer = []
139
+
140
+ first.forEach((f) => {
141
+ const matches = second.filter((s) => condition(f, s)).map((m) => ({ ...m, ...f }))
142
+ inner = inner.concat(matches)
143
+ if (matches.length === 0) outer.push(f)
144
+ })
145
+ return { inner, outer }
146
+ }
147
+
148
+ /**
149
+ * Filters the results of the first dataset based on the condition using the second dataset.
150
+ *
151
+ * @param {Array} first - The first dataset to filter.
152
+ * @param {Array} second - The second dataset to filter by.
153
+ * @param {Function} condition - The condition to filter the first dataset by.
154
+ * @returns {Array} - The filtered dataset.
155
+ */
156
+ function antiJoin(first, second, condition) {
157
+ return first.filter((f) => !second.find((s) => condition(f, s)))
158
+ }
159
+
160
+ /**
161
+ * Joins two datasets together based on a condition. Result includes all rows from the first
162
+ * dataset and matching rows from the second dataset. In case of multiple matches, all
163
+ * combinations are returned. When combining the rows, the columns from the first dataset take
164
+ * precedence.
165
+ *
166
+ * @param {Array} first - The first dataset to join.
167
+ * @param {Array} second - The second dataset to join.
168
+ * @param {Function} condition - The condition to join the datasets on.
169
+ * @returns {Array} - The joined dataset.
170
+ */
171
+ function innerJoin(first, second, condition) {
172
+ const { inner } = joinData(first, second, condition)
173
+ return inner
174
+ }
175
+
176
+ /**
177
+ * Performs a left join on two datasets based on a condition. Result includes all rows from the first
178
+ * dataset and matching rows from the second dataset. In case of multiple matches, all combinations
179
+ * are returned. When combining the rows, the columns from the first dataset take precedence.
180
+ * If there is no match in the second dataset, only the row from the first dataset is returned.
181
+ *
182
+ * @param {Array} first - The first dataset to join.
183
+ * @param {Array} second - The second dataset to join.
184
+ * @param {Function} condition - The condition to join the datasets on.
185
+ * @returns {Array} - The joined dataset.
186
+ */
187
+ function leftJoin(first, second, condition) {
188
+ const { inner, outer } = joinData(first, second, condition)
189
+ return inner.concat(outer)
190
+ }
191
+
192
+ /**
193
+ * Performs a right join on two datasets based on a condition. Result includes all rows from the second
194
+ * dataset and matching rows from the first dataset. In case of multiple matches, all combinations
195
+ * are returned. When combining the rows, the columns from the second dataset take precedence.
196
+ * If there is no match in the first dataset, only the row from the second dataset is returned.
197
+ *
198
+ * @param {Array} first - The first dataset to join.
199
+ * @param {Array} second - The second dataset to join.
200
+ * @param {Function} condition - The condition to join the datasets on.
201
+ * @returns {Array} - The joined dataset.
202
+ */
203
+ function rightJoin(first, second, condition) {
204
+ const { inner, outer } = joinData(second, first, condition)
205
+ return inner.concat(outer)
206
+ }
207
+
208
+ /**
209
+ * Performs a full join on two datasets based on a condition. Result includes all rows from both
210
+ * datasets. In case of multiple matches, all combinations are returned. When combining the rows,
211
+ * the columns from the first dataset take precedence. If there is no match in the second dataset,
212
+ * only the row from the first dataset is returned. If there is no match in the first dataset, only
213
+ * the row from the second dataset is returned.
214
+ *
215
+ * @param {Array} first - The first dataset to join.
216
+ * @param {Array} second - The second dataset to join.
217
+ * @param {Function} condition - The condition to join the datasets on.
218
+ * @returns {Array} - The joined dataset.
219
+ */
220
+ function fullJoin(first, second, condition) {
221
+ const { inner, outer } = joinData(first, second, condition)
222
+ const rightOuter = antiJoin(second, first, condition)
223
+ return inner.concat(outer).concat(rightOuter)
224
+ }
225
+
226
+ /**
227
+ * Performs a cross join on two datasets. Result includes all combinations of rows from both datasets.
228
+ *
229
+ * @param {Array} first - The first dataset to join.
230
+ * @param {Array} second - The second dataset to join.
231
+ * @returns {Array} - The joined dataset.
232
+ */
233
+ function crossJoin(first, second) {
234
+ return first.map((f) => second.map((s) => ({ ...f, ...s }))).flat()
235
+ }
236
+
237
+ /**
238
+ * Performs a semi join on two datasets based on a condition. Result includes all rows from the first
239
+ * dataset that have a match in the second dataset.
240
+ *
241
+ * @param {Array} first - The first dataset to join.
242
+ * @param {Array} second - The second dataset to join.
243
+ * @param {Function} condition - The condition to join the datasets on.
244
+ * @returns {Array} - The joined dataset.
245
+ */
246
+ function semiJoin(first, second, condition) {
247
+ return first.filter((f) => second.find((s) => condition(f, s)))
248
+ }
249
+
250
+ /**
251
+ * Performs a nested join on two datasets based on a condition. Result includes all rows from the first
252
+ * dataset that have a match in the second dataset. The result is nested with the matching rows from the
253
+ * second dataset.
254
+ *
255
+ * @param {Array} first - The first dataset to join.
256
+ * @param {Array} second - The second dataset to join.
257
+ * @param {Function} condition - The condition to join the datasets on.
258
+ * @param {String} [key='children'] - The key to nest the matching rows under.
259
+ * @returns {Array} - The joined dataset.
260
+ */
261
+ function nestedJoin(first, second, condition, key = 'children') {
262
+ const result = first.map((f) => ({
263
+ ...f,
264
+ [key]: second.filter((s) => condition(f, s))
265
+ }))
266
+ return result
267
+ }
268
+
269
+ /**
270
+ * Renames the keys of the data based on the provided mapping, or function
271
+ * that returns the new key. If the new key already exists, the value is overwritten.
272
+ *
273
+ * @param {Array<Object>} data - The data to rename the keys of.
274
+ * @param {Object|Function} how - The mapping of old keys to new keys, or a function that performs renaming.
275
+ * returns Array<Object> - The updated data with renamed keys.
276
+ */
277
+ function renameKeys(data, how) {
278
+ const rename = typeof how === 'function' ? how : renameKeysUsingMap(how)
279
+ return data.map(rename)
280
+ }
281
+
282
+ /**
283
+ * Drops the specified keys from the data.
284
+ *
285
+ * @param {Array<Object>} data - The data to drop keys from.
286
+ * @param {Array<String>} keys - The keys to drop.
287
+ * @returns {Array<Object>} - The updated data with the specified keys dropped.
288
+ */
289
+ function dropKeys(data, ...keys) {
290
+ return data.map((row) => omit(keys, row))
291
+ }
292
+
293
+ /**
294
+ * Deletes rows from the data based on the condition
295
+ *
296
+ * @param {Array<Object>} data - The data to delete rows from.
297
+ * @param {Function} condition - The condition to delete rows on.
298
+ * @returns {Array<Object>} - The updated data with rows deleted based on the condition.
299
+ */
300
+ function deleteRows(data, condition) {
301
+ return data.filter((row) => !condition(row))
302
+ }
303
+
304
+ /**
305
+ * Updates rows in the data based on the condition
306
+ *
307
+ * @param {Array<Object>} data - The data to update rows in.
308
+ * @param {Function} filter - The condition to update rows on.
309
+ * @param {Object|Function} value - The values to update the rows with.
310
+ * @returns {Array<Object>} - The updated data with rows updated based on the condition.
311
+ */
312
+ function updateRows(data, filter, value) {
313
+ if (typeof value !== 'function' && typeof value !== 'object') {
314
+ throw new Error('Value must be an object or function')
315
+ }
316
+ return data.map((row) =>
317
+ filter(row) ? (typeof value === 'function' ? value(row) : { ...row, ...value }) : row
318
+ )
319
+ }
320
+
321
+ /**
322
+ * Fills missing values in the data based on the provided value.
323
+ *
324
+ * @param {Array<Object>} data - The data to fill missing values in.
325
+ * @param {Object} value - The value to fill missing values with.
326
+ * @returns {Array<Object>} - The updated data with missing values filled.
327
+ */
328
+ function fillNA(data, value) {
329
+ /**
330
+ * Fills missing values in a single row based on the provided value.
331
+ *
332
+ * @param {Object} row - The row to fill missing values in.
333
+ */
334
+ const fill = (row) => {
335
+ const filled = {}
336
+ Object.entries(value).forEach(([k, v]) => {
337
+ if (row[k] === undefined || row[k] === null) {
338
+ filled[k] = v
339
+ }
340
+ })
341
+ return { ...row, ...filled }
342
+ }
343
+ return data.map(fill)
344
+ }
345
+
346
+ /**
347
+ * Returns a function that renames the keys of a single row based on the provided mapping.
348
+ *
349
+ * @param {Object} lookup - The mapping of old keys to new keys.
350
+ * @returns {Function} - The function that renames the keys of a single row.
351
+ */
352
+ function renameKeysUsingMap(lookup) {
353
+ return (row) =>
354
+ Object.entries(row)
355
+ .sort((a, b) => descending(a[0], b[0]))
356
+ .reduce((acc, [k, v]) => ({ ...acc, [lookup[k] || k]: v }), {})
357
+ }
358
+
359
+ /**
360
+ * Sorts data by the specified columns.
361
+ *
362
+ * @param {Array<Object>} data - The data to sort.
363
+ * @param {import('./types').SortableColumn} fields - The columns to sort by.
364
+ * @returns {Array<Object>} The sorted data
365
+ */
366
+ function sortDataBy(data, ...fields) {
367
+ const sorters = fields.map(deriveSortableColumn)
368
+
369
+ data.sort((a, b) => {
370
+ let result = 0
371
+ for (let i = 0; i < sorters.length && result === 0; i++) {
372
+ const { name, sorter } = sorters[i]
373
+ result = sorter(a[name], b[name])
374
+ }
375
+ return result
376
+ })
377
+ return data
378
+ }
379
+
380
+ /**
381
+ * Selects the specified columns from the dataset.
382
+ *
383
+ * @param {Array<Object>} data - The data to select from.
384
+ * @param {Object} config - The configuration object.
385
+ * @param {Function} config.filter - The filter function to apply to the data.
386
+ * @param {Array<String>} cols - The columns to select.
387
+ * @returns {Array<Object>} The selected data.
388
+ */
389
+ function selectKeys(data, config, ...cols) {
390
+ const result = config.filter ? data.filter(config.filter) : data
391
+ if (cols.length > 0) return result.map(pick(cols))
392
+ return result
393
+ }
394
+
395
+ /**
396
+ * Summarizes the dataset by the specified columns.
397
+ * @param {import('./types').dataset} df - The dataset object to summarize.
398
+ * @param {Array} summaries - The columns to summarize.
399
+ *
400
+ * @returns {import('./types').dataset} The summarized dataset object.
401
+ */
402
+ function rollup(data, config) {
403
+ if (config.group_by.length === 0 && config.summaries.length === 0) {
404
+ throw new Error(
405
+ 'Use groupBy to specify the columns to group by or use summarize to add aggregators.'
406
+ )
407
+ }
408
+
409
+ const summaries = clone(config.summaries)
410
+ const hasAlignBy = config.align_by.length > 0
411
+ if (summaries.length === 0) summaries.push(defaultAggregator(config))
412
+
413
+ let alignedData = groupDataByKeys(data, config.group_by, summaries)
414
+
415
+ if (hasAlignBy) {
416
+ const fillRows = getAlignGenerator(data, config)
417
+ alignedData = fillAlignedData(alignedData, config, fillRows)
418
+ alignedData.forEach((group) => {
419
+ group[config.children] = sortDataBy(group[config.children], ...config.align_by)
420
+ })
421
+ }
422
+
423
+ const aggregatedData = aggregateData(alignedData, summaries)
424
+ // const newMetadata = buildMetadata(aggregatedData, df.metadata, df.config.group_by, summaries)
425
+
426
+ return aggregatedData
427
+ }
428
+
429
+ /**
430
+ * Returns the default aggregator which rolls up the columns other than the group_by columns into a single object.
431
+ *
432
+ * @param {import('./types').Metadata} metadata - The metadata for the columns to be aggregated.
433
+ * @param {Object} config - The configuration used to build the aggregator.
434
+ *
435
+ * @returns {Object} An object containing the default aggregator for the specified metadata and configuration.
436
+ */
437
+ export function defaultAggregator(config) {
438
+ return {
439
+ mapper: omit(config.group_by),
440
+ reducers: [{ field: config.children, formula: identity }]
441
+ }
442
+ }
package/src/filter.js ADDED
@@ -0,0 +1,57 @@
1
+ import { filterOperations } from './constants'
2
+
3
+ /**
4
+ * Filters an array of objects based on a specified condition defined in the options.
5
+ * If a column is provided, it filters based on the value of that column. Otherwise, it filters
6
+ * based on a value present in any of the object's properties.
7
+ *
8
+ * @param {Object[]} data - The array of objects to filter.
9
+ * @param {Object} options - The filtering options that define the condition.
10
+ * @param {string} [options.column] - The property name in the objects to filter by. If omitted, all properties are considered.
11
+ * @param {string|number} [options.value] - The value to compare against when filtering.
12
+ * @param {string} [options.operator] - The operator to use for comparison, should match a key in the filterOperations object.
13
+ * @returns {Object[]} - The filtered array of objects.
14
+ */
15
+ export function filterObjectArray(data, options) {
16
+ const { column, value, operator } = options
17
+ // If no operator or value is given, return the data unchanged.
18
+ if (!operator || !value) return data
19
+ // If a column is specified, perform the filtering on that specific column.
20
+ if (column) return data.filter((row) => filterOperations[operator](row[column], value))
21
+
22
+ return data.filter((row) => findMatchingRow(row, value, operator))
23
+ }
24
+
25
+ /**
26
+ * Checks if a row matches the given value based on the specified operator.
27
+ * Supports both standard and negated operators by checking the operator string
28
+ * for a leading '!' character, which indicates negation.
29
+ *
30
+ * @param {Object} row - The row object in which to search for the value match.
31
+ * @param {any} value - The value to match against the row's properties.
32
+ * @param {string} operator - The operator used to compare the row's properties to the value.
33
+ * When operator starts with '!', it negates the result of the comparison.
34
+ * @returns {boolean} - Returns true if the row matches the condition; otherwise, returns false.
35
+ */
36
+ function findMatchingRow(row, value, operator) {
37
+ const op = operator.startsWith('!') ? operator.slice(1) : operator
38
+ const matched = Object.keys(row).some((key) => filterOperations[op](row[key], value))
39
+
40
+ return operator.startsWith('!') ? !matched : matched
41
+ }
42
+
43
+ /**
44
+ * Applies multiple filtering conditions to an array of objects. This function iterates through each filter option,
45
+ * successively narrowing down the data.
46
+ *
47
+ * @param {Object[]} data - The array of objects to filter.
48
+ * @param {Object[]} filters - An array of filtering options to apply to the data.
49
+ * @returns {Object[]} - The filtered array of objects that meet all conditions specified by the filters.
50
+ */
51
+ export function filterData(data, filters) {
52
+ let filteredData = data
53
+ filters.forEach((filter) => {
54
+ filteredData = filterObjectArray(filteredData, filter)
55
+ })
56
+ return filteredData
57
+ }
@@ -0,0 +1,56 @@
1
+ import { identity } from 'ramda'
2
+
3
+ const formatterMap = {
4
+ currency: getCurrencyFormatter,
5
+ integer: (language) => (val) => val.toLocaleString(language, { maximumFractionDigits: 0 }),
6
+ number: (language, decimalPlaces) => (val) =>
7
+ val.toLocaleString(language, {
8
+ minimumFractionDigits: decimalPlaces,
9
+ maximumFractionDigits: decimalPlaces
10
+ }),
11
+ date: (language) => (val) => val.toLocaleDateString(language),
12
+ time: (language) => (val) => val.toLocaleTimeString(language),
13
+ object: () => JSON.stringify,
14
+ array: () => JSON.stringify, // Combined formatter for 'object' and 'array'
15
+ ellipsis: () => () => '...' // Static formatter, no need for parameters
16
+ }
17
+
18
+ /**
19
+ * Returns a currency formatter function that formats a value as a currency string.
20
+ * @param {string} language - The IETF language tag used for locale-specific formatting.
21
+ * @param {number} decimalPlaces - The number of decimal places to show with currency formatting.
22
+ * @returns {function} - A currency formatter function that takes a value and returns a formatted string.
23
+ */
24
+ function getCurrencyFormatter(language, decimalPlaces) {
25
+ /**
26
+ * Formats a value as a currency string with the desired decimal places.
27
+ *
28
+ * @param {number} val - The value to format as a currency string.
29
+ * @param {string} [currency='USD'] - The currency code to use for formatting.
30
+ * @returns {string} - The formatted currency string.
31
+ */
32
+ const formatter = (val, currency = 'USD') =>
33
+ val.toLocaleString(language, {
34
+ style: 'currency',
35
+ currency,
36
+ minimumFractionDigits: decimalPlaces,
37
+ maximumFractionDigits: decimalPlaces
38
+ })
39
+ return formatter
40
+ }
41
+
42
+ /**
43
+ * Creates a formatter function that formats a value according to the specified type and optional locale settings.
44
+ * Supported types include 'default' (no formatting), 'integer', 'number', 'date', 'time',, 'object', 'array', 'currency' & 'ellipsis''
45
+ *
46
+ * @param {string} type - The type of the formatter to use (e.g., 'integer', 'number', 'date', 'time', 'currency').
47
+ * @param {string} [language='en-US'] - Optional IETF language tag used for locale-specific formatting.
48
+ * @param {number} [decimalPlaces=2] - Optional number of decimal places to show with number and currency formatting.
49
+ * @returns {*} - A format function that takes a value and returns a formatted string.
50
+ */
51
+ export function createFormatter(type, language = 'en-US', decimalPlaces = 2) {
52
+ const formatter = formatterMap[type]
53
+ if (formatter) return formatter(language, decimalPlaces)
54
+
55
+ return identity
56
+ }
@@ -0,0 +1,136 @@
1
+ import { defaultViewOptions } from './constants'
2
+ /**
3
+ * Removes objects from an array where the parent attribute is not null.
4
+ * @param {Array<Object>} arr - The array of objects.
5
+ */
6
+ export function removeChildren(arr) {
7
+ for (let i = arr.length - 1; i >= 0; i--) {
8
+ if (arr[i].parent) arr.splice(i, 1)
9
+ }
10
+ }
11
+ /**
12
+ * Flatten nested children arrays within an array of objects.
13
+ * @param {Array<Object>} arr - The array of objects.
14
+ */
15
+ export function flattenNestedChildren(arr) {
16
+ let index = 0
17
+ while (index < arr.length) {
18
+ if (Array.isArray(arr[index].children) && arr[index].children.length > 0) {
19
+ const children = arr[index].children
20
+ arr.splice(index + 1, 0, ...children)
21
+ }
22
+ index++
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Recursively updates parent items' flags based on the child's flags.
28
+ * @param {Object} item - The current object.
29
+ */
30
+ function updateParentFlags(item) {
31
+ if (!item.parent) return
32
+ if (item.parent.excluded) {
33
+ item.parent.excluded = false
34
+ item.parent.retainedByChild = true
35
+
36
+ updateParentFlags(item.parent)
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Applies a hierarchical filter to an array of objects.
42
+ * @param {Array<Object>} arr - The array of objects.
43
+ * @param {Function} filterFn - The filter function to apply on each object.
44
+ */
45
+ export function hierarchicalFilter(arr, filterFn) {
46
+ arr.forEach((item) => {
47
+ item.excluded = !filterFn(item)
48
+ })
49
+
50
+ arr.forEach((item) => {
51
+ if (!item.excluded) updateParentFlags(item)
52
+ })
53
+ }
54
+
55
+ /**
56
+ * Derives the hierarchy from the data.
57
+ *
58
+ * @param {Array} data - The data to derive the hierarchy from.
59
+ * @param {string} path - The column name to be used as hierarchical path.
60
+ * @param {string} separator - The separator to be used in the path.
61
+ * @returns {Array<import('./types').Hierarchy>} - The derived hierarchy.
62
+ */
63
+ export function deriveHierarchy(data, options) {
64
+ const { expanded, path, separator } = { ...defaultViewOptions, ...options }
65
+ if (!path) return data.map((row) => ({ depth: 0, row }))
66
+
67
+ const hierarchy = data.map((row) => {
68
+ const parts = row[path].split(separator).filter((part) => part.length > 0)
69
+ const depth = parts.length
70
+ const value = depth > 0 ? parts[depth - 1] : ''
71
+ return { depth, value, path: row[path], row }
72
+ })
73
+
74
+ hierarchy.forEach((row) => {
75
+ row.children = hierarchy.filter(
76
+ (child) => child.path.startsWith(row.path) && row.depth === child.depth - 1
77
+ )
78
+
79
+ row.isParent = row.children.length > 0
80
+ if (row.isParent) {
81
+ row.isExpanded = expanded
82
+ row.isHidden = !row.parent ? false : row.parent.isHidden || !row.parent.isExpanded
83
+ }
84
+ row.children.forEach((child) => {
85
+ child.parent = row
86
+ child.isHidden = row.isHidden || !row.isExpanded
87
+ })
88
+ })
89
+ return hierarchy
90
+ }
91
+
92
+ /**
93
+ * Recursively sets the visibility of child nodes based on the hidden and expanded state
94
+ * of their parent node.
95
+ *
96
+ * @param {Object} node - The parent node whose children's visibility will be changed.
97
+ * The node is expected to have a 'children' property which is an
98
+ * array of child nodes, a 'isHidden' property indicating if the
99
+ * node is hidden, and a 'isExpanded' property indicating if the
100
+ * node is expanded.
101
+ */
102
+ function changeVisibilityForChildren(node) {
103
+ if (node.children) {
104
+ node.children.forEach((child) => {
105
+ child.isHidden = node.isHidden || !node.isExpanded
106
+ changeVisibilityForChildren(child)
107
+ })
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Toggles the expansion state of a node and updates the visibility of its children accordingly.
113
+ * If the node is a parent (has child nodes), it will invert its 'isExpanded' property and call
114
+ * 'changeVisibilityForChildren' to reflect changes to its child nodes.
115
+ *
116
+ * @param {Object} node - The node to toggle expansion for. The node is expected to have an
117
+ * 'isParent' property indicating if the node has children, an
118
+ * 'isExpanded' property indicating if the node is expanded, and an array
119
+ * of child nodes under the 'children' property if it is a parent.
120
+ */
121
+ export function toggleExpansion(node) {
122
+ if (node.isParent) {
123
+ node.isExpanded = !node.isExpanded
124
+ changeVisibilityForChildren(node)
125
+ }
126
+ }
127
+
128
+ // function hierarchy(data, options){
129
+ // const nodes = deriveHierarchy(data, options)
130
+
131
+ // return {
132
+ // nodes,
133
+ // filter: (fn) => hierarchicalFilter(nodes, fn),
134
+ // selection:
135
+ // }
136
+ // }