@cdc/core 4.25.11 → 4.26.1

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.
Files changed (77) hide show
  1. package/_stories/Gallery.Charts.stories.tsx +307 -0
  2. package/_stories/Gallery.DataBite.stories.tsx +72 -0
  3. package/_stories/Gallery.Maps.stories.tsx +230 -0
  4. package/_stories/Gallery.WaffleChart.stories.tsx +187 -0
  5. package/_stories/PageART.stories.tsx +192 -0
  6. package/_stories/PageBRFSS.stories.tsx +289 -0
  7. package/_stories/PageCancerRegistries.stories.tsx +199 -0
  8. package/_stories/PageEasternEquineEncephalitis.stories.tsx +202 -0
  9. package/_stories/PageExcessiveAlcoholUse.stories.tsx +196 -0
  10. package/_stories/PageMaternalMortality.stories.tsx +192 -0
  11. package/_stories/PageOralHealth.stories.tsx +196 -0
  12. package/_stories/PageRespiratory.stories.tsx +332 -0
  13. package/_stories/PageSmokingTobacco.stories.tsx +195 -0
  14. package/_stories/PageStateDiabetesProfiles.stories.tsx +196 -0
  15. package/_stories/PageWastewater.stories.tsx +463 -0
  16. package/assets/icon-magnifying-glass.svg +5 -0
  17. package/assets/icon-warming-stripes.svg +13 -0
  18. package/components/AdvancedEditor/AdvancedEditor.tsx +4 -0
  19. package/components/AdvancedEditor/EmbedEditor.tsx +281 -0
  20. package/components/ComboBox/ComboBox.tsx +345 -0
  21. package/components/ComboBox/combobox.styles.css +185 -0
  22. package/components/ComboBox/index.ts +1 -0
  23. package/components/DataTable/DataTable.tsx +132 -58
  24. package/components/DataTable/data-table.css +216 -215
  25. package/components/DataTable/helpers/mapCellMatrix.tsx +14 -6
  26. package/components/EditorPanel/ColumnsEditor.tsx +37 -19
  27. package/components/EditorPanel/DataTableEditor.tsx +51 -25
  28. package/components/EditorPanel/EditorPanel.styles.css +16 -0
  29. package/components/EditorPanel/EditorPanel.tsx +144 -0
  30. package/components/EditorPanel/EditorPanelDispatch.tsx +75 -0
  31. package/components/EditorPanel/FieldSetWrapper.tsx +66 -23
  32. package/components/EditorPanel/Inputs.tsx +33 -7
  33. package/components/EditorPanel/VizFilterEditor/VizFilterEditor.tsx +236 -175
  34. package/components/EditorPanel/sections/VisualSection.tsx +169 -0
  35. package/components/Filters/Filters.tsx +31 -5
  36. package/components/Filters/helpers/getNestedOptions.ts +2 -1
  37. package/components/Filters/helpers/handleSorting.ts +1 -1
  38. package/components/Layout/components/Sidebar/components/sidebar.styles.scss +82 -0
  39. package/components/Layout/components/Visualization/index.tsx +16 -1
  40. package/components/Layout/components/Visualization/visualizations.scss +7 -0
  41. package/components/Legend/Legend.Gradient.tsx +1 -1
  42. package/components/MediaControls.tsx +53 -27
  43. package/components/ui/Icon.tsx +3 -1
  44. package/components/ui/Title/index.tsx +30 -2
  45. package/components/ui/Title/title.styles.css +42 -0
  46. package/dist/cove-main.css +26 -3
  47. package/dist/cove-main.css.map +1 -1
  48. package/generateViteConfig.js +8 -1
  49. package/helpers/addValuesToFilters.ts +6 -1
  50. package/helpers/coveUpdateWorker.ts +19 -12
  51. package/helpers/embedCodeGenerator.ts +109 -0
  52. package/helpers/getUniqueValues.ts +19 -0
  53. package/helpers/hashObj.ts +25 -0
  54. package/helpers/isRightAlignedTableValue.js +5 -0
  55. package/helpers/metrics/helpers.ts +1 -0
  56. package/helpers/pivotData.ts +2 -2
  57. package/helpers/prepareScreenshot.ts +268 -0
  58. package/helpers/queryStringUtils.ts +29 -0
  59. package/helpers/tests/prepareScreenshot.test.ts +414 -0
  60. package/helpers/tests/queryStringUtils.test.ts +381 -0
  61. package/helpers/tests/testStandaloneBuild.ts +23 -5
  62. package/helpers/useDataVizClasses.ts +0 -1
  63. package/helpers/ver/4.26.1.ts +80 -0
  64. package/hooks/useDataColumns.ts +63 -0
  65. package/hooks/useFilterManagement.ts +94 -0
  66. package/hooks/useLegendSeparators.ts +26 -0
  67. package/hooks/useListManagement.ts +192 -0
  68. package/package.json +4 -3
  69. package/styles/_button-section.scss +0 -3
  70. package/types/Axis.ts +1 -0
  71. package/types/ForecastingSeriesKey.ts +1 -0
  72. package/types/MarkupInclude.ts +1 -0
  73. package/types/Series.ts +3 -0
  74. package/types/Table.ts +1 -0
  75. package/types/Visualization.ts +1 -0
  76. package/types/VizFilter.ts +1 -0
  77. package/LICENSE +0 -201
@@ -0,0 +1,381 @@
1
+ import { getQueryStringFilterValue, isFilterHiddenByQuery, getQueryParams, getQueryParam } from '../queryStringUtils'
2
+ import { expect, describe, it, beforeEach, afterEach, vi } from 'vitest'
3
+
4
+ describe('getQueryStringFilterValue', () => {
5
+ let originalLocation: Location
6
+
7
+ beforeEach(() => {
8
+ // Save original location
9
+ originalLocation = window.location
10
+ })
11
+
12
+ afterEach(() => {
13
+ // Restore original location
14
+ Object.defineProperty(window, 'location', {
15
+ value: originalLocation,
16
+ writable: true,
17
+ configurable: true
18
+ })
19
+ })
20
+
21
+ const mockLocation = (search: string) => {
22
+ delete (window as any).location
23
+ Object.defineProperty(window, 'location', {
24
+ value: { search },
25
+ writable: true,
26
+ configurable: true
27
+ })
28
+ }
29
+
30
+ it('should return the matching filter value from query string (case-insensitive)', () => {
31
+ mockLocation('?location=home')
32
+ const filter = {
33
+ setByQueryParameter: 'location',
34
+ values: ['Home', 'School', 'Work']
35
+ }
36
+ const result = getQueryStringFilterValue(filter)
37
+ expect(result).toBe('Home') // Returns value with original casing
38
+ })
39
+
40
+ it('should return the matching filter value when query param is uppercase', () => {
41
+ mockLocation('?state=CALIFORNIA')
42
+ const filter = {
43
+ setByQueryParameter: 'state',
44
+ values: ['California', 'Texas', 'New York']
45
+ }
46
+ const result = getQueryStringFilterValue(filter)
47
+ expect(result).toBe('California')
48
+ })
49
+
50
+ it('should return undefined when setByQueryParameter is not defined', () => {
51
+ mockLocation('?location=home')
52
+ const filter = {
53
+ values: ['Home', 'School', 'Work']
54
+ }
55
+ const result = getQueryStringFilterValue(filter)
56
+ expect(result).toBeUndefined()
57
+ })
58
+
59
+ it('should return undefined when query parameter is not in URL', () => {
60
+ mockLocation('?other=value')
61
+ const filter = {
62
+ setByQueryParameter: 'location',
63
+ values: ['Home', 'School', 'Work']
64
+ }
65
+ const result = getQueryStringFilterValue(filter)
66
+ expect(result).toBeUndefined()
67
+ })
68
+
69
+ it('should return undefined when query value does not match any filter values', () => {
70
+ mockLocation('?location=park')
71
+ const filter = {
72
+ setByQueryParameter: 'location',
73
+ values: ['Home', 'School', 'Work']
74
+ }
75
+ const result = getQueryStringFilterValue(filter)
76
+ expect(result).toBeUndefined()
77
+ })
78
+
79
+ it('should return undefined when filter has no values', () => {
80
+ mockLocation('?location=home')
81
+ const filter = {
82
+ setByQueryParameter: 'location',
83
+ values: []
84
+ }
85
+ const result = getQueryStringFilterValue(filter)
86
+ expect(result).toBeUndefined()
87
+ })
88
+
89
+ it('should handle filters with null values in array', () => {
90
+ mockLocation('?location=school')
91
+ const filter = {
92
+ setByQueryParameter: 'location',
93
+ values: ['Home', null, 'School', 'Work']
94
+ }
95
+ const result = getQueryStringFilterValue(filter)
96
+ expect(result).toBe('School')
97
+ })
98
+ })
99
+
100
+ describe('isFilterHiddenByQuery', () => {
101
+ let originalLocation: Location
102
+
103
+ beforeEach(() => {
104
+ originalLocation = window.location
105
+ })
106
+
107
+ afterEach(() => {
108
+ Object.defineProperty(window, 'location', {
109
+ value: originalLocation,
110
+ writable: true,
111
+ configurable: true
112
+ })
113
+ })
114
+
115
+ const mockLocation = (search: string) => {
116
+ delete (window as any).location
117
+ Object.defineProperty(window, 'location', {
118
+ value: { search },
119
+ writable: true,
120
+ configurable: true
121
+ })
122
+ }
123
+
124
+ it('should return true when query parameter is "true"', () => {
125
+ mockLocation('?hideState=true')
126
+ const filter = {
127
+ setByQueryParameter: 'State'
128
+ }
129
+ const result = isFilterHiddenByQuery(filter)
130
+ expect(result).toBe(true)
131
+ })
132
+
133
+ it('should return true when query parameter is "TRUE" (case insensitive)', () => {
134
+ mockLocation('?hideState=TRUE')
135
+ const filter = {
136
+ setByQueryParameter: 'State'
137
+ }
138
+ const result = isFilterHiddenByQuery(filter)
139
+ expect(result).toBe(true)
140
+ })
141
+
142
+ it('should return true when query parameter is "1"', () => {
143
+ mockLocation('?hideLocation=1')
144
+ const filter = {
145
+ setByQueryParameter: 'Location'
146
+ }
147
+ const result = isFilterHiddenByQuery(filter)
148
+ expect(result).toBe(true)
149
+ })
150
+
151
+ it('should return true when query parameter is "yes"', () => {
152
+ mockLocation('?hideYear=yes')
153
+ const filter = {
154
+ setByQueryParameter: 'Year'
155
+ }
156
+ const result = isFilterHiddenByQuery(filter)
157
+ expect(result).toBe(true)
158
+ })
159
+
160
+ it('should return true when query parameter is "YES" (case insensitive)', () => {
161
+ mockLocation('?hideYear=YES')
162
+ const filter = {
163
+ setByQueryParameter: 'Year'
164
+ }
165
+ const result = isFilterHiddenByQuery(filter)
166
+ expect(result).toBe(true)
167
+ })
168
+
169
+ it('should return false when setByQueryParameter is not defined', () => {
170
+ mockLocation('?hideState=true')
171
+ const filter = {}
172
+ const result = isFilterHiddenByQuery(filter)
173
+ expect(result).toBe(false)
174
+ })
175
+
176
+ it('should return false when filter is null', () => {
177
+ mockLocation('?hideState=true')
178
+ const result = isFilterHiddenByQuery(null)
179
+ expect(result).toBe(false)
180
+ })
181
+
182
+ it('should return false when filter is undefined', () => {
183
+ mockLocation('?hideState=true')
184
+ const result = isFilterHiddenByQuery(undefined)
185
+ expect(result).toBe(false)
186
+ })
187
+
188
+ it('should return false when query parameter is not in URL', () => {
189
+ mockLocation('?other=value')
190
+ const filter = {
191
+ setByQueryParameter: 'State'
192
+ }
193
+ const result = isFilterHiddenByQuery(filter)
194
+ expect(result).toBe(false)
195
+ })
196
+
197
+ it('should return false when query parameter is "false"', () => {
198
+ mockLocation('?hideState=false')
199
+ const filter = {
200
+ setByQueryParameter: 'State'
201
+ }
202
+ const result = isFilterHiddenByQuery(filter)
203
+ expect(result).toBe(false)
204
+ })
205
+
206
+ it('should return false when query parameter is "0"', () => {
207
+ mockLocation('?hideLocation=0')
208
+ const filter = {
209
+ setByQueryParameter: 'Location'
210
+ }
211
+ const result = isFilterHiddenByQuery(filter)
212
+ expect(result).toBe(false)
213
+ })
214
+
215
+ it('should return false when query parameter is "no"', () => {
216
+ mockLocation('?hideYear=no')
217
+ const filter = {
218
+ setByQueryParameter: 'Year'
219
+ }
220
+ const result = isFilterHiddenByQuery(filter)
221
+ expect(result).toBe(false)
222
+ })
223
+
224
+ it('should return false when query parameter is empty string', () => {
225
+ mockLocation('?hideState=')
226
+ const filter = {
227
+ setByQueryParameter: 'State'
228
+ }
229
+ const result = isFilterHiddenByQuery(filter)
230
+ expect(result).toBe(false)
231
+ })
232
+
233
+ it('should return false when query parameter has an arbitrary value', () => {
234
+ mockLocation('?hideState=maybe')
235
+ const filter = {
236
+ setByQueryParameter: 'State'
237
+ }
238
+ const result = isFilterHiddenByQuery(filter)
239
+ expect(result).toBe(false)
240
+ })
241
+
242
+ it('should handle multiple query parameters correctly', () => {
243
+ mockLocation('?location=home&hideState=true&year=2024')
244
+ const filter = {
245
+ setByQueryParameter: 'State'
246
+ }
247
+ const result = isFilterHiddenByQuery(filter)
248
+ expect(result).toBe(true)
249
+ })
250
+
251
+ it('should not hide when different parameter is true', () => {
252
+ mockLocation('?hideLocation=true&hideState=false')
253
+ const filter = {
254
+ setByQueryParameter: 'State'
255
+ }
256
+ const result = isFilterHiddenByQuery(filter)
257
+ expect(result).toBe(false)
258
+ })
259
+
260
+ it('should construct parameter name with exact case from setByQueryParameter', () => {
261
+ mockLocation('?hidecolor=true')
262
+ const filter = {
263
+ setByQueryParameter: 'color'
264
+ }
265
+ const result = isFilterHiddenByQuery(filter)
266
+ expect(result).toBe(true)
267
+ })
268
+
269
+ it('should work with multi-word parameter names', () => {
270
+ mockLocation('?hideAnimal Category=1')
271
+ const filter = {
272
+ setByQueryParameter: 'Animal Category'
273
+ }
274
+ const result = isFilterHiddenByQuery(filter)
275
+ expect(result).toBe(true)
276
+ })
277
+
278
+ it('should only check setByQueryParameter (not key, label, or columnName)', () => {
279
+ mockLocation('?hidegeography=true')
280
+ const filter = {
281
+ setByQueryParameter: 'State',
282
+ key: 'geography',
283
+ label: 'geography',
284
+ columnName: 'geography'
285
+ }
286
+ const result = isFilterHiddenByQuery(filter)
287
+ expect(result).toBe(false) // Should NOT match because setByQueryParameter is 'State', not 'geography'
288
+ })
289
+
290
+ it('should match when setByQueryParameter matches URL param', () => {
291
+ mockLocation('?hideState=true')
292
+ const filter = {
293
+ setByQueryParameter: 'State',
294
+ key: 'geography',
295
+ label: 'Select Geography',
296
+ columnName: 'geography'
297
+ }
298
+ const result = isFilterHiddenByQuery(filter)
299
+ expect(result).toBe(true) // Should match because setByQueryParameter is 'State'
300
+ })
301
+ })
302
+
303
+ describe('getQueryParams', () => {
304
+ let originalLocation: Location
305
+
306
+ beforeEach(() => {
307
+ originalLocation = window.location
308
+ })
309
+
310
+ afterEach(() => {
311
+ Object.defineProperty(window, 'location', {
312
+ value: originalLocation,
313
+ writable: true,
314
+ configurable: true
315
+ })
316
+ })
317
+
318
+ const mockLocation = (search: string) => {
319
+ delete (window as any).location
320
+ Object.defineProperty(window, 'location', {
321
+ value: { search },
322
+ writable: true,
323
+ configurable: true
324
+ })
325
+ }
326
+
327
+ it('should return an object of query parameters', () => {
328
+ mockLocation('?state=CA&year=2024')
329
+ const result = getQueryParams()
330
+ expect(result).toEqual({ state: 'CA', year: '2024' })
331
+ })
332
+
333
+ it('should return empty object when no query parameters', () => {
334
+ mockLocation('')
335
+ const result = getQueryParams()
336
+ expect(result).toEqual({})
337
+ })
338
+
339
+ it('should handle multiple values for the same key', () => {
340
+ mockLocation('?tag=health&tag=safety&tag=education')
341
+ const result = getQueryParams()
342
+ expect(result.tag).toEqual(['health', 'safety', 'education'])
343
+ })
344
+ })
345
+
346
+ describe('getQueryParam', () => {
347
+ let originalLocation: Location
348
+
349
+ beforeEach(() => {
350
+ originalLocation = window.location
351
+ })
352
+
353
+ afterEach(() => {
354
+ Object.defineProperty(window, 'location', {
355
+ value: originalLocation,
356
+ writable: true,
357
+ configurable: true
358
+ })
359
+ })
360
+
361
+ const mockLocation = (search: string) => {
362
+ delete (window as any).location
363
+ Object.defineProperty(window, 'location', {
364
+ value: { search },
365
+ writable: true,
366
+ configurable: true
367
+ })
368
+ }
369
+
370
+ it('should return the value of a specific query parameter', () => {
371
+ mockLocation('?state=CA&year=2024')
372
+ const result = getQueryParam('state')
373
+ expect(result).toBe('CA')
374
+ })
375
+
376
+ it('should return undefined for non-existent parameter', () => {
377
+ mockLocation('?state=CA')
378
+ const result = getQueryParam('year')
379
+ expect(result).toBeUndefined()
380
+ })
381
+ })
@@ -21,24 +21,42 @@ function copyDirSync(src, dest) {
21
21
  }
22
22
  }
23
23
 
24
+ // Tests if a package can be built in isolation
25
+ // See DOCS/PACKAGE_DEPENDENCIES.md for more details
24
26
  export function testStandaloneBuild(pkgDir) {
25
- // This test can't be turned on until we've published the new version of @cdc/core
26
- return true
27
-
28
27
  pkgDir = pkgDir.replace('/src', '')
29
28
  const pkgName = pkgDir.split('/')[pkgDir.split('/').length - 1]
30
29
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `cdc-open-viz-${pkgName}-`))
31
30
  copyDirSync(pkgDir, tmpDir)
32
31
 
32
+ let coreTarballPath = null
33
+
33
34
  try {
34
- execSync('npm install', { cwd: tmpDir })
35
- execSync('npm link @cdc/core', { cwd: tmpDir })
35
+ execSync('npm install --include=dev', { cwd: tmpDir })
36
+
37
+ // Pack core into tmp directory with unique name then install from package tarball
38
+ const coreDir = path.join(pkgDir, '..', 'core')
39
+ const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(7)}`
40
+ const uniqueTarballDir = fs.mkdtempSync(path.join(os.tmpdir(), `cdc-pack-${uniqueId}-`))
41
+ const packOutput = execSync(`npm pack --pack-destination="${uniqueTarballDir}"`, {
42
+ cwd: coreDir,
43
+ encoding: 'utf-8'
44
+ })
45
+ const tarballName = packOutput.trim().split('\n').pop()
46
+ coreTarballPath = path.join(uniqueTarballDir, tarballName)
47
+ execSync(`npm install "${coreTarballPath}"`, { cwd: tmpDir, stdio: 'inherit' })
48
+
36
49
  execSync('npm run build', { cwd: tmpDir })
37
50
  return true
38
51
  } catch (err) {
39
52
  console.error(`❌ Isolated build for ${pkgName} package failed`)
53
+ console.error(err.message)
40
54
  return false
41
55
  } finally {
56
+ if (coreTarballPath && fs.existsSync(coreTarballPath)) {
57
+ const uniqueTarballDir = path.dirname(coreTarballPath)
58
+ fs.rmSync(uniqueTarballDir, { recursive: true, force: true })
59
+ }
42
60
  fs.rmSync(tmpDir, { recursive: true, force: true })
43
61
  }
44
62
  }
@@ -1,4 +1,3 @@
1
- import useResizeObserver from '@cdc/map/src/hooks/useResizeObserver'
2
1
  import { isBelowBreakpoint } from './viewports'
3
2
 
4
3
  export default function useDataVizClasses(config, viewport = null) {
@@ -0,0 +1,80 @@
1
+ import cloneConfig from '../cloneConfig'
2
+ import { DashboardConfig } from '@cdc/dashboard/src/types/DashboardConfig'
3
+
4
+ const normalizeFilterParents = config => {
5
+ if (config.type === 'dashboard') {
6
+ if (config.dashboard?.sharedFilters) {
7
+ config.dashboard.sharedFilters.forEach(filter => {
8
+ if (filter.type === 'datafilter' && filter.parents && typeof filter.parents === 'string') {
9
+ filter.parents = [filter.parents]
10
+ }
11
+ })
12
+ }
13
+ }
14
+ }
15
+
16
+ const removeOldBrushKeys = config => {
17
+ if (config.type === 'chart') {
18
+ // Remove old brush object entirely - brush feature is new in 4.26.1
19
+ // and any existing brush config is from development/testing
20
+ delete config.brush
21
+ }
22
+
23
+ if (config.type === 'dashboard') {
24
+ Object.values((config as DashboardConfig).visualizations).forEach(visualization => {
25
+ removeOldBrushKeys(visualization)
26
+ })
27
+ }
28
+ }
29
+
30
+ const migrateTitleStyle = config => {
31
+ // Migrate ALL visualizations to use titleStyle
32
+ // Since titleStyle is new in 4.26.1, any config running this migration won't have it yet
33
+ // so we can unconditionally set it based on whether a title exists
34
+ // - If title exists and is not empty: use 'legacy' (preserve existing appearance)
35
+ // - If title is empty/missing: use 'small' (new default)
36
+
37
+ if (config.type === 'dashboard') {
38
+ // Migrate dashboard title
39
+ if (!config.dashboard) config.dashboard = {}
40
+ const hasTitle = config.dashboard.title && config.dashboard.title.trim() !== ''
41
+ config.dashboard.titleStyle = hasTitle ? 'legacy' : 'small'
42
+
43
+ // Migrate all visualizations in dashboard
44
+ if (config.visualizations) {
45
+ Object.values((config as DashboardConfig).visualizations).forEach(visualization => {
46
+ migrateTitleStyle(visualization)
47
+ })
48
+ }
49
+ } else if (config.type === 'map') {
50
+ // Map stores titleStyle under general
51
+ if (!config.general) config.general = {}
52
+ const hasTitle = config.general.title && config.general.title.trim() !== ''
53
+ config.general.titleStyle = hasTitle ? 'legacy' : 'small'
54
+ } else if (config.type === 'markup-include') {
55
+ // Markup-include stores titleStyle under contentEditor (same location as title)
56
+ if (!config.contentEditor) config.contentEditor = {}
57
+ const hasTitle = config.contentEditor.title && config.contentEditor.title.trim() !== ''
58
+ config.contentEditor.titleStyle = hasTitle ? 'legacy' : 'small'
59
+ } else if (config.type === 'data-bite' || config.type === 'waffle-chart') {
60
+ // Data bites and waffle charts always use legacy title style - no migration needed
61
+ // The components hardcode 'legacy' for the Title component
62
+ } else if (config.type) {
63
+ // For all other visualization types (chart, filtered-text, etc.)
64
+ // titleStyle is at root level
65
+ const hasTitle = config.title && config.title.trim() !== ''
66
+ config.titleStyle = hasTitle ? 'legacy' : 'small'
67
+ }
68
+ }
69
+
70
+ const update_4_26_1 = config => {
71
+ const ver = '4.26.1'
72
+ const newConfig = cloneConfig(config)
73
+ normalizeFilterParents(newConfig)
74
+ removeOldBrushKeys(newConfig)
75
+ migrateTitleStyle(newConfig)
76
+ newConfig.version = ver
77
+ return newConfig
78
+ }
79
+
80
+ export default update_4_26_1
@@ -0,0 +1,63 @@
1
+ import { useMemo } from 'react'
2
+
3
+ export interface UseDataColumnsOptions {
4
+ /** Columns to exclude from the result */
5
+ excludeColumns?: string[]
6
+ /** Include only columns with specific data types */
7
+ dataTypes?: ('string' | 'number' | 'boolean' | 'date')[]
8
+ }
9
+
10
+ /**
11
+ * Extracts unique column names from data with memoization
12
+ *
13
+ * Performance optimization: Replaces the common getColumns() pattern
14
+ * that was duplicated across multiple packages and called multiple times per render.
15
+ *
16
+ * @param data - Array of data objects
17
+ * @param options - Optional configuration for filtering columns
18
+ * @returns Sorted array of unique column names
19
+ *
20
+ * @example
21
+ * // Basic usage
22
+ * const columns = useDataColumns(data)
23
+ *
24
+ * @example
25
+ * // With exclusions
26
+ * const columns = useDataColumns(data, { excludeColumns: [config.groupBy] })
27
+ *
28
+ * @example
29
+ * // Filter by data type
30
+ * const numericColumns = useDataColumns(data, { dataTypes: ['number'] })
31
+ */
32
+ export const useDataColumns = (data: any[], options?: UseDataColumnsOptions): string[] => {
33
+ const { excludeColumns = [], dataTypes } = options || {}
34
+
35
+ return useMemo(() => {
36
+ if (!data?.length) return []
37
+
38
+ const columnsSet = new Set<string>()
39
+
40
+ // Single iteration through all rows (optimized from previous pattern)
41
+ data.forEach(row => {
42
+ Object.keys(row).forEach(columnName => {
43
+ if (excludeColumns.includes(columnName)) return
44
+
45
+ // Optional: filter by data type
46
+ if (dataTypes && dataTypes.length > 0) {
47
+ const value = row[columnName]
48
+ const valueType = typeof value
49
+ if (!dataTypes.includes(valueType as any)) return
50
+ }
51
+
52
+ columnsSet.add(columnName)
53
+ })
54
+ })
55
+
56
+ return Array.from(columnsSet).sort()
57
+ }, [
58
+ data,
59
+ // Stringify arrays for stable dependency tracking
60
+ excludeColumns.join(','),
61
+ dataTypes?.join(',')
62
+ ])
63
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Custom hook for managing filter operations in visualizations
3
+ *
4
+ * Provides common filter management functionality including:
5
+ * - Adding new filters
6
+ * - Removing filters
7
+ * - Updating filter properties
8
+ * - Getting unique column values for filter options
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * const { addNewFilter, removeFilter, updateFilterProp, getFilterColumnValues } =
13
+ * useFilterManagement(config, updateConfig, data)
14
+ *
15
+ * // Add a new filter
16
+ * <Button onClick={addNewFilter}>Add Filter</Button>
17
+ *
18
+ * // Remove a filter
19
+ * <button onClick={() => removeFilter(index)}>Remove</button>
20
+ *
21
+ * // Update filter property
22
+ * <Select onChange={e => updateFilterProp('columnName', index, e.target.value)} />
23
+ *
24
+ * // Get column values for filter dropdown
25
+ * <Select options={getFilterColumnValues(index)} />
26
+ * ```
27
+ */
28
+ export const useFilterManagement = <TConfig extends { filters?: any[] }>(
29
+ config: TConfig,
30
+ updateConfig: (config: TConfig) => void,
31
+ data: any[]
32
+ ) => {
33
+ /**
34
+ * Adds a new empty filter to the config
35
+ */
36
+ const addNewFilter = () => {
37
+ const filters = config.filters ? [...config.filters] : []
38
+ filters.push({ values: [] })
39
+ updateConfig({ ...config, filters })
40
+ }
41
+
42
+ /**
43
+ * Removes a filter at the specified index
44
+ * @param index - The index of the filter to remove
45
+ */
46
+ const removeFilter = (index: number) => {
47
+ const filters = [...(config.filters || [])]
48
+ filters.splice(index, 1)
49
+ updateConfig({ ...config, filters })
50
+ }
51
+
52
+ /**
53
+ * Updates a specific property of a filter
54
+ * @param name - The property name to update
55
+ * @param index - The index of the filter
56
+ * @param value - The new value for the property
57
+ */
58
+ const updateFilterProp = (name: string, index: number, value: any) => {
59
+ const filters = [...(config.filters || [])]
60
+ filters[index][name] = value
61
+ updateConfig({ ...config, filters })
62
+ }
63
+
64
+ /**
65
+ * Gets unique values from a data column for filter options
66
+ * @param index - The index of the filter (to get its columnName)
67
+ * @returns Array of unique, sorted values from the specified column
68
+ */
69
+ const getFilterColumnValues = (index: number): any[] => {
70
+ const filterDataOptions: any[] = []
71
+ const filterColumnName = config.filters?.[index]?.columnName
72
+
73
+ // Return empty array if no column name or no data
74
+ if (!filterColumnName || !data || !Array.isArray(data) || data.length === 0) {
75
+ return filterDataOptions
76
+ }
77
+
78
+ data.forEach(function (row: any) {
79
+ if (undefined !== row[filterColumnName] && -1 === filterDataOptions.indexOf(row[filterColumnName])) {
80
+ filterDataOptions.push(row[filterColumnName])
81
+ }
82
+ })
83
+ filterDataOptions.sort()
84
+
85
+ return filterDataOptions
86
+ }
87
+
88
+ return {
89
+ addNewFilter,
90
+ removeFilter,
91
+ updateFilterProp,
92
+ getFilterColumnValues
93
+ }
94
+ }
@@ -0,0 +1,26 @@
1
+ import { clamp } from 'lodash'
2
+
3
+ // TODO: generalize this to be used in legends other than linear block gradient
4
+
5
+ const LEGEND_SEPARATOR_SIZE = 0.02
6
+ const LEGEND_SEPARATOR_SIZE_MAX = 20
7
+ const LEGEND_SEPARATOR_SIZE_MIN = 8
8
+
9
+ const useLegendSeparators = (separators, legendWidth, allowsLegendSeparators) => {
10
+ const legendSeparators = allowsLegendSeparators
11
+ ? separators?.replace(' ', '').split(',').map(Number).filter(Boolean) || []
12
+ : []
13
+ const separatorSize = clamp(legendWidth * LEGEND_SEPARATOR_SIZE, LEGEND_SEPARATOR_SIZE_MIN, LEGEND_SEPARATOR_SIZE_MAX)
14
+ const legendSeparatorsToSubtract = legendSeparators.length * separatorSize
15
+ const getTickSeparatorsAdjustment = (index: number) =>
16
+ legendSeparators.reduce((acc, separators) => (index >= separators ? acc + separatorSize : acc), 0)
17
+
18
+ return {
19
+ legendSeparators,
20
+ separatorSize,
21
+ legendSeparatorsToSubtract,
22
+ getTickSeparatorsAdjustment
23
+ }
24
+ }
25
+
26
+ export default useLegendSeparators