@cdc/chart 4.24.12-2 → 4.25.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 (70) hide show
  1. package/dist/cdcchart.js +79411 -78816
  2. package/examples/feature/boxplot/boxplot.json +2 -157
  3. package/examples/feature/boxplot/testing.csv +23 -38
  4. package/examples/feature/tests-non-numerics/example-combo-bar-nonnumeric.json +394 -30
  5. package/examples/private/not-loading.json +360 -0
  6. package/index.html +7 -14
  7. package/package.json +2 -2
  8. package/src/CdcChart.tsx +92 -1512
  9. package/src/CdcChartComponent.tsx +1105 -0
  10. package/src/_stories/Chart.Anchors.stories.tsx +1 -1
  11. package/src/_stories/Chart.CustomColors.stories.tsx +1 -1
  12. package/src/_stories/Chart.DynamicSeries.stories.tsx +1 -1
  13. package/src/_stories/Chart.Legend.Gradient.stories.tsx +2 -2
  14. package/src/_stories/Chart.ScatterPlot.stories.tsx +19 -0
  15. package/src/_stories/Chart.tooltip.stories.tsx +1 -2
  16. package/src/_stories/ChartAnnotation.stories.tsx +1 -1
  17. package/src/_stories/ChartAxisLabels.stories.tsx +1 -1
  18. package/src/_stories/ChartAxisTitles.stories.tsx +1 -1
  19. package/src/_stories/ChartEditor.stories.tsx +1 -1
  20. package/src/_stories/ChartLine.Suppression.stories.tsx +1 -1
  21. package/src/_stories/ChartLine.Symbols.stories.tsx +18 -0
  22. package/src/_stories/ChartPrefixSuffix.stories.tsx +1 -1
  23. package/src/_stories/_mock/line_chart_symbols.json +437 -0
  24. package/src/_stories/_mock/scatterplot-image-download.json +1244 -0
  25. package/src/components/Annotations/components/AnnotationDraggable.tsx +3 -11
  26. package/src/components/Annotations/components/AnnotationDropdown.tsx +3 -3
  27. package/src/components/Axis/Categorical.Axis.tsx +3 -4
  28. package/src/components/BarChart/components/BarChart.Horizontal.tsx +14 -5
  29. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +10 -4
  30. package/src/components/BoxPlot/BoxPlot.tsx +34 -32
  31. package/src/components/BoxPlot/helpers/index.ts +108 -18
  32. package/src/components/DeviationBar.jsx +2 -6
  33. package/src/components/EditorPanel/EditorPanel.tsx +62 -6
  34. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +4 -0
  35. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +44 -7
  36. package/src/components/ForestPlot/ForestPlot.tsx +176 -26
  37. package/src/components/Legend/Legend.Component.tsx +29 -38
  38. package/src/components/Legend/Legend.Suppression.tsx +3 -5
  39. package/src/components/Legend/Legend.tsx +2 -2
  40. package/src/components/Legend/LegendLine.Shape.tsx +51 -0
  41. package/src/components/Legend/helpers/createFormatLabels.tsx +29 -26
  42. package/src/components/Legend/helpers/getLegendClasses.ts +20 -38
  43. package/src/components/Legend/helpers/index.ts +14 -7
  44. package/src/components/Legend/tests/getLegendClasses.test.ts +3 -20
  45. package/src/components/LineChart/components/LineChart.Circle.tsx +90 -88
  46. package/src/components/LineChart/index.tsx +4 -0
  47. package/src/components/LinearChart.tsx +54 -29
  48. package/src/components/PairedBarChart.jsx +2 -9
  49. package/src/components/ZoomBrush.tsx +5 -7
  50. package/src/data/initial-state.js +6 -3
  51. package/src/helpers/getBoxPlotConfig.ts +68 -0
  52. package/src/helpers/getColorScale.ts +28 -0
  53. package/src/helpers/getComboChartConfig.ts +42 -0
  54. package/src/helpers/getExcludedData.ts +37 -0
  55. package/src/helpers/getTopAxis.ts +7 -0
  56. package/src/hooks/useBarChart.ts +28 -9
  57. package/src/hooks/{useHighlightedBars.js → useHighlightedBars.ts} +2 -1
  58. package/src/hooks/useIntersectionObserver.ts +37 -0
  59. package/src/hooks/useMinMax.ts +4 -0
  60. package/src/hooks/useReduceData.ts +1 -1
  61. package/src/hooks/useTooltip.tsx +9 -1
  62. package/src/index.jsx +1 -0
  63. package/src/scss/DataTable.scss +0 -5
  64. package/src/scss/main.scss +30 -115
  65. package/src/types/ChartConfig.ts +6 -3
  66. package/src/types/ChartContext.ts +1 -3
  67. package/src/helpers/getQuartiles.ts +0 -27
  68. package/src/hooks/useColorScale.ts +0 -50
  69. package/src/hooks/useIntersectionObserver.jsx +0 -29
  70. package/src/hooks/useTopAxis.js +0 -6
package/src/CdcChart.tsx CHANGED
@@ -1,167 +1,47 @@
1
- import React, { useState, useEffect, useCallback, useRef, useId, useMemo } from 'react'
2
-
3
- // IE11
4
- import ResizeObserver from 'resize-observer-polyfill'
5
- import 'whatwg-fetch'
6
- import * as d3 from 'd3-array'
7
- import Layout from '@cdc/core/components/Layout'
8
- import Button from '@cdc/core/components/elements/Button'
9
-
10
- //types
11
- import { DimensionsType } from '@cdc/core/types/Dimensions'
12
- import { type DashboardConfig } from '@cdc/dashboard/src/types/DashboardConfig'
13
-
14
- // External Libraries
15
- import { scaleOrdinal } from '@visx/scale'
16
- import ParentSize from '@visx/responsive/lib/components/ParentSize'
17
- import { timeParse, timeFormat } from 'd3-time-format'
1
+ import React, { useEffect, useState, useCallback, useRef, useContext } from 'react'
2
+ import CdcChart from './CdcChartComponent'
3
+ import { ChartConfig } from './types/ChartConfig'
4
+ import { getFileExtension } from '@cdc/core/helpers/getFileExtension'
5
+ import { isSolrCsv, isSolrJson } from '@cdc/core/helpers/isSolr'
18
6
  import Papa from 'papaparse'
19
- import parse from 'html-react-parser'
20
7
  import 'react-tooltip/dist/react-tooltip.css'
21
-
22
- // Primary Components
23
- import ConfigContext from './ConfigContext'
24
- import PieChart from './components/PieChart'
25
- import SankeyChart from './components/Sankey'
26
- import LinearChart from './components/LinearChart'
27
- import { isDateScale } from '@cdc/core/helpers/cove/date'
28
-
29
- import { colorPalettesChart as colorPalettes, twoColorPalette } from '@cdc/core/data/colorPalettes'
30
-
31
- import SparkLine from './components/Sparkline'
32
- import Legend from './components/Legend'
33
- import defaults from './data/initial-state'
34
- import EditorPanel from './components/EditorPanel'
35
- import { abbreviateNumber } from './helpers/abbreviateNumber'
36
- import { handleChartTabbing } from './helpers/handleChartTabbing'
37
- import { getQuartiles } from './helpers/getQuartiles'
38
- import { handleChartAriaLabels } from './helpers/handleChartAriaLabels'
39
- import { lineOptions } from './helpers/lineOptions'
40
- import { handleLineType } from './helpers/handleLineType'
41
- import { handleRankByValue } from './helpers/handleRankByValue'
42
- import { generateColorsArray } from './helpers/generateColorsArray'
43
- import Loading from '@cdc/core/components/Loading'
44
- import Filters from '@cdc/core/components/Filters'
45
- import MediaControls from '@cdc/core/components/MediaControls'
46
- import Annotation from './components/Annotations'
47
-
48
- // Helpers
49
- import { publish, subscribe, unsubscribe } from '@cdc/core/helpers/events'
50
- import useDataVizClasses from '@cdc/core/helpers/useDataVizClasses'
51
- import numberFromString from '@cdc/core/helpers/numberFromString'
52
- import getViewport from '@cdc/core/helpers/getViewport'
53
- import { DataTransform } from '@cdc/core/helpers/DataTransform'
54
8
  import cacheBustingString from '@cdc/core/helpers/cacheBustingString'
55
- import isNumber from '@cdc/core/helpers/isNumber'
56
- import coveUpdateWorker from '@cdc/core/helpers/coveUpdateWorker'
57
- import { isConvertLineToBarGraph } from './helpers/isConvertLineToBarGraph'
58
- import { isLegendWrapViewport } from '@cdc/core/helpers/viewports'
59
-
60
- import './scss/main.scss'
61
- // load both then config below determines which to use
62
- import DataTable from '@cdc/core/components/DataTable'
63
- import type { TableConfig } from '@cdc/core/components/DataTable/types/TableConfig'
64
- import { getFileExtension } from '@cdc/core/helpers/getFileExtension'
65
- import Title from '@cdc/core/components/ui/Title'
66
- import { AllChartsConfig, ChartConfig } from './types/ChartConfig'
67
- import { Label } from './types/Label'
68
- import { type ViewportSize } from './types/ChartConfig'
69
- import { isSolrCsv, isSolrJson } from '@cdc/core/helpers/isSolr'
70
- import SkipTo from '@cdc/core/components/elements/SkipTo'
71
- import { filterVizData } from '@cdc/core/helpers/filterVizData'
72
- import LegendWrapper from './components/LegendWrapper'
9
+ import Loading from '@cdc/core/components/Loading'
73
10
  import _ from 'lodash'
74
- import { addValuesToFilters } from '@cdc/core/helpers/addValuesToFilters'
75
- import { Runtime } from '@cdc/core/types/Runtime'
76
- import { Pivot } from '@cdc/core/types/Table'
77
-
11
+ import EditorContext from '../../editor/src/ConfigContext'
78
12
  interface CdcChartProps {
79
13
  configUrl?: string
80
- config?: ChartConfig
81
14
  isEditor?: boolean
82
15
  isDebug?: boolean
83
- isDashboard?: boolean
84
- setConfig?: (config: ChartConfig) => void
85
- setEditing?: (editing: boolean) => void
86
- hostname?: string
87
- link?: string
88
- setSharedFilter?: (filter: any) => void
89
- setSharedFilterValue?: (value: any) => void
90
- dashboardConfig?: DashboardConfig
16
+ config?: ChartConfig
91
17
  }
92
- const CdcChart = ({
93
- configUrl,
94
- config: configObj,
95
- isEditor = false,
96
- isDebug = false,
97
- isDashboard = false,
98
- setConfig: setParentConfig,
99
- setEditing,
100
- hostname,
101
- link,
102
- setSharedFilter,
103
- setSharedFilterValue,
104
- dashboardConfig
105
- }: CdcChartProps) => {
106
- const transform = new DataTransform()
107
- const [loading, setLoading] = useState(true)
108
- const svgRef = useRef(null)
109
- const [colorScale, setColorScale] = useState(null)
110
- const [config, setConfig] = useState<ChartConfig>({} as ChartConfig)
111
- const [stateData, setStateData] = useState(_.cloneDeep(configObj?.data) || [])
112
- const [excludedData, setExcludedData] = useState<Record<string, number>[] | undefined>(undefined)
113
- const [filteredData, setFilteredData] = useState<Record<string, any>[] | undefined>(undefined)
114
- const [seriesHighlight, setSeriesHighlight] = useState<string[]>(
115
- configObj && configObj?.legend?.seriesHighlight?.length ? [...configObj?.legend?.seriesHighlight] : []
116
- )
117
- const [currentViewport, setCurrentViewport] = useState<ViewportSize>('lg')
118
- const [dimensions, setDimensions] = useState<DimensionsType>([0, 0])
119
- const [externalFilters, setExternalFilters] = useState<any[]>()
120
- const [container, setContainer] = useState()
121
- const [coveLoadedEventRan, setCoveLoadedEventRan] = useState(false)
122
- const [isDraggingAnnotation, setIsDraggingAnnotation] = useState(false)
123
- const [dynamicLegendItems, setDynamicLegendItems] = useState<any[]>([])
124
- const [imageId] = useState(`cove-${Math.random().toString(16).slice(-4)}`)
125
- const [brushConfig, setBrushConfig] = useState({
126
- data: [],
127
- isActive: false,
128
- isBrushing: false
129
- })
130
-
131
- const { description, visualizationType } = config
132
-
133
- const legendRef = useRef(null)
134
- const parentRef = useRef(null)
135
-
136
- const handleDragStateChange = isDragging => {
137
- setIsDraggingAnnotation(isDragging)
138
- }
139
-
140
- if (isDebug) console.log('Chart config, isEditor', config, isEditor)
141
-
142
- // Destructure items from config for more readable JSX
143
- let { legend, title } = config
144
18
 
145
- // set defaults on titles if blank AND only in editor
146
- if (isEditor) {
147
- if (!title || title === '') title = 'Chart Title'
19
+ const CdcChartWrapper: React.FC<CdcChartProps> = ({ configUrl, isEditor, isDebug, config: editorsConfig }) => {
20
+ const editorContext = useContext(EditorContext)
21
+ const [config, _setConfig] = useState<ChartConfig>({} as ChartConfig)
22
+ const setConfig = newConfig => {
23
+ _setConfig(newConfig)
24
+ if (isEditor) {
25
+ editorContext.setTempConfig(newConfig)
26
+ }
148
27
  }
28
+ const [isLoading, setIsLoading] = useState(true)
29
+ const prevFiltersRef = useRef(config.filters)
149
30
 
150
- if (config.table && (!config.table?.label || config.table?.label === '')) config.table.label = 'Data Table'
151
-
152
- const { lineDatapointClass, contentClasses, sparkLineStyles } = useDataVizClasses(config)
153
- const legendId = useId()
154
-
155
- const checkLineToBarGraph = () => {
156
- return isConvertLineToBarGraph(config.visualizationType, filteredData, config.allowLineToBarGraph)
157
- }
31
+ const loadConfig = useCallback(
32
+ async (url: string) => {
33
+ const response = editorsConfig || (await (await fetch(url)).json())
34
+ return response
35
+ },
36
+ [editorsConfig]
37
+ )
158
38
 
159
- const reloadURLData = async () => {
39
+ const reloadFilteredData = useCallback(async () => {
160
40
  if (config.dataUrl) {
161
41
  const dataUrl = new URL(config.runtimeDataUrl || config.dataUrl, window.location.origin)
162
- let qsParams = Object.fromEntries(new URLSearchParams(dataUrl.search))
163
-
42
+ const qsParams = Object.fromEntries(new URLSearchParams(dataUrl.search))
164
43
  let isUpdateNeeded = false
44
+
165
45
  config.filters?.forEach(filter => {
166
46
  if (filter.type === 'url' && qsParams[filter.queryParameter] !== decodeURIComponent(filter.active)) {
167
47
  qsParams[filter.queryParameter] = filter.active
@@ -169,1394 +49,94 @@ const CdcChart = ({
169
49
  }
170
50
  })
171
51
 
172
- if ((!config.formattedData || config.formattedData.urlFiltered) && !isUpdateNeeded) return
173
-
174
- let dataUrlFinal = `${dataUrl.origin}${dataUrl.pathname}${Object.keys(qsParams)
175
- .map((param, i) => {
176
- let qs = i === 0 ? '?' : '&'
177
- qs += param + '='
178
- qs += qsParams[param]
179
- return qs
180
- })
181
- .join('')}`
182
-
183
- let data: any[] = []
184
-
185
- try {
186
- const ext = getFileExtension(dataUrl.href)
187
- if ('csv' === ext || isSolrCsv(dataUrlFinal)) {
188
- data = await fetch(dataUrlFinal)
189
- .then(response => response.text())
190
- .then(responseText => {
191
- const parsedCsv = Papa.parse(responseText, {
192
- header: true,
193
- dynamicTyping: true,
194
- skipEmptyLines: true
195
- })
196
- return parsedCsv.data
197
- })
198
- } else if ('json' === ext || isSolrJson(dataUrlFinal)) {
199
- data = await fetch(dataUrlFinal).then(response => response.json())
200
- } else {
201
- data = []
202
- }
203
- } catch {
204
- console.error(`Cannot parse URL: ${dataUrlFinal}`)
205
- data = []
206
- }
207
-
208
- if (config.dataDescription) {
209
- data = transform.autoStandardize(data)
210
- data = transform.developerStandardize(data, config.dataDescription)
211
- }
212
-
213
- Object.assign(data, { urlFiltered: true })
214
-
215
- data = handleRankByValue(data, config)
216
-
217
- updateConfig({ ...config, runtimeDataUrl: dataUrlFinal, data, formattedData: data })
218
-
219
- if (data) {
220
- setStateData(data)
221
- setExcludedData(data)
222
- setFilteredData(filterVizData(config.filters, data))
223
- }
224
- }
225
- }
226
-
227
- const loadConfig = async () => {
228
- const response = _.cloneDeep(configObj) || (await (await fetch(configUrl)).json())
229
-
230
- // If data is included through a URL, fetch that and store
231
- let data: any[] = response.data || []
232
-
233
- const urlFilters = response.filters
234
- ? response.filters.filter(filter => filter.type === 'url').length > 0
235
- ? true
236
- : false
237
- : false
238
-
239
- if (response.dataUrl && !urlFilters) {
240
- try {
241
- const ext = getFileExtension(response.dataUrl)
242
- if ('csv' === ext || isSolrCsv(response.dataUrl)) {
243
- data = await fetch(response.dataUrl + `?v=${cacheBustingString()}`)
244
- .then(response => response.text())
245
- .then(responseText => {
246
- // for every comma NOT inside quotes, replace with a pipe delimiter
247
- // - this will let commas inside the quotes not be parsed as a new column
248
- // - Limitation: if a delimiter other than comma is used in the csv this will break
249
- // Examples of other delimiters that would break: tab
250
- responseText = responseText.replace(/(".*?")|,/g, (...m) => m[1] || '|')
251
- // now strip the double quotes
252
- responseText = responseText.replace(/["]+/g, '')
253
- const parsedCsv = Papa.parse(responseText, {
254
- //quotes: "true", // dont need these
255
- //quoteChar: "'", // has no effect that I can tell
256
- header: true,
257
- dynamicTyping: true,
258
- skipEmptyLines: true,
259
- delimiter: '|' // we are using pipe symbol as delimiter so setting this explicitly for Papa.parse
260
- })
261
- return parsedCsv.data
262
- })
263
- }
264
-
265
- if ('json' === ext || isSolrJson(response.dataUrl)) {
266
- data = await fetch(response.dataUrl + `?v=${cacheBustingString()}`).then(response => response.json())
267
- }
268
- } catch {
269
- console.error(`COVE: Cannot parse URL: ${response.dataUrl}`) // eslint-disable-line
270
- data = []
271
- }
272
- }
273
-
274
- if (response.dataDescription) {
275
- data = transform.autoStandardize(data)
276
- data = transform.developerStandardize(data, response.dataDescription)
277
- }
278
-
279
- data = handleRankByValue(data, response)
280
-
281
- if (data) {
282
- setStateData(data)
283
- setExcludedData(data)
284
- }
285
-
286
- // force showVertical for data tables false if it does not exist
287
- if (response !== undefined && response.table !== undefined) {
288
- if (!response.table || !response.table.showVertical) {
289
- response.table = response.table || {}
290
- response.table.showVertical = false
291
- }
292
- }
293
- let newConfig = { ...defaults, ...response }
294
-
295
- if (undefined === newConfig.table.show) newConfig.table.show = !isDashboard
296
-
297
- newConfig.series.forEach(series => {
298
- if (series.tooltip === undefined || series.tooltip === null) {
299
- series.tooltip = true
300
- }
301
- if (!series.axis) series.axis = 'Left'
302
- })
303
-
304
- if (data) {
305
- newConfig.data = data
306
- }
307
-
308
- const processedConfig = { ...coveUpdateWorker(newConfig) }
309
-
310
- updateConfig(processedConfig, data)
311
- }
312
-
313
- const updateConfig = (_config: AllChartsConfig, dataOverride?: any[]) => {
314
- const newConfig = _.cloneDeep(_config)
315
- let data = dataOverride || stateData
316
-
317
- data = handleRankByValue(data, newConfig)
318
-
319
- // Deeper copy
320
- Object.keys(defaults).forEach(key => {
321
- if (newConfig[key] && 'object' === typeof newConfig[key] && !Array.isArray(newConfig[key])) {
322
- newConfig[key] = { ...defaults[key], ...newConfig[key] }
323
- }
324
- })
325
-
326
- let newExcludedData: any[] = []
327
-
328
- if (newConfig.exclusions && newConfig.exclusions.active) {
329
- if (newConfig.xAxis.type === 'categorical' && newConfig.exclusions.keys?.length > 0) {
330
- newExcludedData = data.filter(e => !newConfig.exclusions.keys.includes(e[newConfig.xAxis.dataKey]))
331
- } else if (
332
- isDateScale(newConfig.xAxis) &&
333
- (newConfig.exclusions.dateStart || newConfig.exclusions.dateEnd) &&
334
- newConfig.xAxis.dateParseFormat
335
- ) {
336
- // Filter dates
337
- const timestamp = e => new Date(e).getTime()
338
-
339
- let startDate = timestamp(newConfig.exclusions.dateStart)
340
- let endDate = timestamp(newConfig.exclusions.dateEnd) + 86399999 //Increase by 24h in ms (86400000ms - 1ms) to include selected end date for .getTime() comparative
341
-
342
- let startDateValid = undefined !== typeof startDate && false === isNaN(startDate)
343
- let endDateValid = undefined !== typeof endDate && false === isNaN(endDate)
344
-
345
- if (startDateValid && endDateValid) {
346
- newExcludedData = data.filter(
347
- e => timestamp(e[newConfig.xAxis.dataKey]) >= startDate && timestamp(e[newConfig.xAxis.dataKey]) <= endDate
348
- )
349
- } else if (startDateValid) {
350
- newExcludedData = data.filter(e => timestamp(e[newConfig.xAxis.dataKey]) >= startDate)
351
- } else if (endDateValid) {
352
- newExcludedData = data.filter(e => timestamp(e[newConfig.xAxis.dataKey]) <= endDate)
353
- }
354
- } else {
355
- newExcludedData = dataOverride || stateData
356
- }
357
- } else {
358
- newExcludedData = dataOverride || stateData
359
- }
360
-
361
- setExcludedData(newExcludedData)
362
-
363
- // After data is grabbed, loop through and generate filter column values if there are any
364
- let currentData: any[] = []
365
- if (newConfig.filters) {
366
- const filtersWithValues = addValuesToFilters(newConfig.filters, newExcludedData)
367
- currentData = filterVizData(filtersWithValues, newExcludedData)
368
- setFilteredData(currentData)
369
- }
370
-
371
- if (newConfig.xAxis.type === 'date-time' && config.orientation === 'horizontal') {
372
- newConfig.xAxis.type = 'date'
373
- }
374
-
375
- //Enforce default values that need to be calculated at runtime
376
- newConfig.runtime = {} as Runtime
377
- newConfig.runtime.series = newConfig.dynamicSeries ? [] : _.cloneDeep(newConfig.series)
378
- newConfig.runtime.seriesLabels = {}
379
- newConfig.runtime.seriesLabelsAll = []
380
- newConfig.runtime.originalXAxis = newConfig.xAxis
381
-
382
- if (newConfig.dynamicSeries) {
383
- let finalData = dataOverride || newConfig.formattedData || newConfig.data
384
- if (finalData?.length) {
385
- Object.keys(finalData[0]).forEach(seriesKey => {
386
- if (
387
- seriesKey !== newConfig.xAxis.dataKey &&
388
- (!newConfig.filters || newConfig.filters.filter(filter => filter.columnName === seriesKey).length === 0) &&
389
- (!newConfig.columns || Object.keys(newConfig.columns).indexOf(seriesKey) === -1)
390
- ) {
391
- newConfig.runtime.series.push({
392
- dataKey: seriesKey,
393
- type: newConfig.dynamicSeriesType,
394
- lineType: newConfig.dynamicSeriesLineType,
395
- tooltip: true
396
- })
397
- }
398
- })
399
- }
400
- }
401
-
402
- if (newConfig.visualizationType === 'Pie') {
403
- newConfig.runtime.seriesKeys = (dataOverride || data).map(d => d[newConfig.xAxis.dataKey])
404
- newConfig.runtime.seriesLabelsAll = newConfig.runtime.seriesKeys
405
- } else {
406
- const finalData = dataOverride || newConfig.formattedData || newConfig.data
407
- newConfig.runtime.seriesKeys = (newConfig.runtime.series || []).flatMap(series => {
408
- if (series.dynamicCategory) {
409
- _.remove(newConfig.runtime.seriesLabelsAll, label => label === series.dataKey)
410
- _.remove(newConfig.runtime.series, s => s.dataKey === series.dataKey)
411
- // grab the dynamic series keys from the data
412
- const seriesKeys: string[] = _.uniq(finalData.map(d => d[series.dynamicCategory]))
413
- // for each of those keys perform side effects
414
- seriesKeys.forEach(dataKey => {
415
- newConfig.runtime.seriesLabels[dataKey] = dataKey
416
- newConfig.runtime.seriesLabelsAll.push(dataKey)
417
- newConfig.runtime.series.push({
418
- dataKey,
419
- type: series.type,
420
- lineType: series.lineType,
421
- originalDataKey: series.dataKey,
422
- dynamicCategory: series.dynamicCategory,
423
- tooltip: true
424
- })
425
- })
426
- // return the series keys
427
- return seriesKeys
428
- } else {
429
- newConfig.runtime.seriesLabels[series.dataKey] = series.name || series.label || series.dataKey
430
- newConfig.runtime.seriesLabelsAll.push(series.name || series.dataKey)
431
- // return the series keys
432
- return [series.dataKey]
433
- }
434
- })
435
- }
436
-
437
- if (newConfig.visualizationType === 'Box Plot' && newConfig.series) {
438
- const combinedData = filteredData || data
439
- const groups = _.uniq(_.map(combinedData, newConfig.xAxis.dataKey))
440
- const seriesKeys = _.map(newConfig.series, 'dataKey')
441
- const plots: any[] = []
442
-
443
- groups.forEach(g => {
444
- seriesKeys.forEach(seriesKey => {
445
- try {
446
- if (!g) throw new Error('No groups resolved in box plots')
447
-
448
- // Start handle operations on combinedData
449
- const { count, sortedData } = _.chain(combinedData)
450
- // Filter by xAxis data key
451
- .filter(item => item[newConfig.xAxis.dataKey] === g)
452
- // perform multiple operations on the filtered data
453
- .thru(filteredData => ({
454
- count: filteredData.length,
455
- sortedData: _.map(filteredData, item => Number(item[seriesKey])).sort()
456
- }))
457
- // get the results from the chain
458
- .value()
459
-
460
- // ! - Notice d3.quantile doesn't work here, and we had to take a custom route.
461
- const quartiles = getQuartiles(sortedData)
462
-
463
- if (!sortedData) throw new Error('boxplots dont have data yet')
464
- if (!plots) throw new Error('boxplots dont have plots yet')
465
-
466
- const q1 = quartiles.q1
467
- const q3 = quartiles.q3
468
-
469
- const iqr = q3 - q1
470
- const lowerBounds = q1 - 1.5 * iqr
471
- const upperBounds = q3 + 1.5 * iqr
472
- const filteredData = sortedData.filter(d => d <= upperBounds)
473
- const max = d3.max(filteredData)
474
- plots.push({
475
- columnCategory: g,
476
- columnMax: max,
477
- columnThirdQuartile: _.round(q3, newConfig.dataFormat.roundTo),
478
- columnMedian: Number(d3.median(sortedData)).toFixed(newConfig.dataFormat.roundTo),
479
- columnFirstQuartile: _.round(q1, newConfig.dataFormat.roundTo),
480
- columnMin: _.min(sortedData),
481
- columnCount: count,
482
- columnSd: Number(d3.deviation(sortedData)).toFixed(newConfig.dataFormat.roundTo),
483
- columnMean: Number(d3.mean(sortedData)).toFixed(newConfig.dataFormat.roundTo),
484
- columnIqr: _.round(iqr, newConfig.dataFormat.roundTo),
485
- values: sortedData,
486
- columnLowerBounds: lowerBounds,
487
- columnUpperBounds: upperBounds,
488
- columnOutliers: _.filter(sortedData, value => value < lowerBounds || value > upperBounds),
489
- columnNonOutliers: _.filter(sortedData, value => value >= lowerBounds && value <= upperBounds)
490
- })
491
- } catch (e) {
492
- console.error('COVE: ', e.message) // eslint-disable-line
493
- }
494
- })
495
- })
496
- // Generate a flat list of categories based on seriesKeys and groups
497
- const categories =
498
- seriesKeys.length > 1
499
- ? _.flatMap(groups, value => _.map(seriesKeys, key => `${_.capitalize(key)} - ${_.capitalize(value)}`))
500
- : groups
501
-
502
- newConfig.boxplot['categories'] = categories
503
- newConfig.boxplot.plots = plots
504
- }
505
-
506
- if (newConfig.visualizationType === 'Combo' && newConfig.series) {
507
- newConfig.runtime.barSeriesKeys = []
508
- newConfig.runtime.lineSeriesKeys = []
509
- newConfig.runtime.areaSeriesKeys = []
510
- newConfig.runtime.forecastingSeriesKeys = []
511
-
512
- newConfig.series.forEach(series => {
513
- if (series.type === 'Area Chart') {
514
- newConfig.runtime.areaSeriesKeys.push(series)
515
- }
516
- if (series.type === 'Forecasting') {
517
- newConfig.runtime.forecastingSeriesKeys.push(series)
518
- }
519
- if (series.type === 'Bar' || series.type === 'Combo') {
520
- newConfig.runtime.barSeriesKeys.push(series.dataKey)
521
- }
522
- if (
523
- series.type === 'Line' ||
524
- series.type === 'dashed-sm' ||
525
- series.type === 'dashed-md' ||
526
- series.type === 'dashed-lg'
527
- ) {
528
- newConfig.runtime.lineSeriesKeys.push(series.dataKey)
529
- }
530
- if (series.type === 'Combo') {
531
- series.type = 'Bar'
532
- }
533
- })
534
- }
535
-
536
- if (newConfig.visualizationType === 'Forecasting' && newConfig.series) {
537
- newConfig.runtime.forecastingSeriesKeys = []
538
-
539
- newConfig.series.forEach(series => {
540
- if (series.type === 'Forecasting') {
541
- newConfig.runtime.forecastingSeriesKeys.push(series)
542
- }
543
- })
544
- }
545
-
546
- if (newConfig.visualizationType === 'Area Chart' && newConfig.series) {
547
- newConfig.runtime.areaSeriesKeys = []
548
-
549
- newConfig.series.forEach(series => {
550
- newConfig.runtime.areaSeriesKeys.push({ ...series, type: 'Area Chart' })
551
- })
552
- }
553
-
554
- if (
555
- (newConfig.visualizationType === 'Bar' && newConfig.orientation === 'horizontal') ||
556
- ['Deviation Bar', 'Paired Bar', 'Forest Plot'].includes(newConfig.visualizationType)
557
- ) {
558
- newConfig.runtime.xAxis = newConfig.yAxis['yAxis'] ? newConfig.yAxis['yAxis'] : newConfig.yAxis
559
- newConfig.runtime.yAxis = newConfig.xAxis['xAxis'] ? newConfig.xAxis['xAxis'] : newConfig.xAxis
560
- newConfig.runtime.yAxis.labelOffset *= -1
561
-
562
- newConfig.runtime.horizontal = false
563
- newConfig.orientation = 'horizontal'
564
- // remove after COVE supports categorical axis on horizonatal bars
565
- newConfig.yAxis.type = newConfig.yAxis.type === 'categorical' ? 'linear' : newConfig.yAxis.type
566
- } else if (
567
- ['Box Plot', 'Scatter Plot', 'Area Chart', 'Line', 'Forecasting'].includes(newConfig.visualizationType) &&
568
- !checkLineToBarGraph()
569
- ) {
570
- newConfig.runtime.xAxis = newConfig.xAxis
571
- newConfig.runtime.yAxis = newConfig.yAxis
572
- newConfig.runtime.horizontal = false
573
- newConfig.orientation = 'vertical'
574
- } else {
575
- newConfig.runtime.xAxis = newConfig.xAxis
576
- newConfig.runtime.yAxis = newConfig.yAxis
577
- newConfig.runtime.horizontal = false
578
- }
579
-
580
- newConfig.runtime.uniqueId = Date.now()
581
- newConfig.runtime.editorErrorMessage =
582
- newConfig.visualizationType === 'Pie' && !newConfig.yAxis.dataKey
583
- ? 'Data Key property in Y Axis section must be set for pie charts.'
584
- : ''
585
-
586
- // Sankey Description box error message
587
- newConfig.runtime.editorErrorMessage = ''
588
-
589
- if (newConfig.legend.seriesHighlight?.length) {
590
- setSeriesHighlight(newConfig.legend?.seriesHighlight)
591
- }
592
-
593
- setConfig(newConfig)
594
- }
595
-
596
- // Sorts data series for horizontal bar charts
597
- const sortData = (a, b) => {
598
- let sortKey =
599
- config.visualizationType === 'Bar' && config.visualizationSubType === 'horizontal'
600
- ? config.xAxis.dataKey
601
- : config.yAxis.sortKey
602
- let aData = parseFloat(a[sortKey])
603
- let bData = parseFloat(b[sortKey])
604
-
605
- if (aData < bData) {
606
- return config.sortData === 'ascending' ? 1 : -1
607
- } else if (aData > bData) {
608
- return config.sortData === 'ascending' ? -1 : 1
609
- } else {
610
- return 0
611
- }
612
- }
613
-
614
- // Observes changes to outermost container and changes viewport size in state
615
- const resizeObserver = new ResizeObserver(entries => {
616
- for (let entry of entries) {
617
- let { width, height } = entry.contentRect
618
- const svgMarginWidth = 15
619
- const editorWidth = 350
620
-
621
- width = isEditor ? width - editorWidth : width
622
-
623
- const newViewport = getViewport(width)
624
-
625
- setCurrentViewport(newViewport)
626
-
627
- if (entry.target.dataset.lollipop === 'true') {
628
- width = width - 2.5
629
- }
630
-
631
- width = width - svgMarginWidth
632
-
633
- setDimensions([width, height])
634
- }
635
- })
636
-
637
- const outerContainerRef = useCallback(node => {
638
- if (node !== null) {
639
- resizeObserver.observe(node)
640
- }
641
-
642
- setContainer(node)
643
- }, []) // eslint-disable-line
644
-
645
- const isEmpty = obj => {
646
- return Object.keys(obj).length === 0
647
- }
648
-
649
- // Load data when component first mounts
650
- useEffect(() => {
651
- loadConfig()
652
- }, [configObj?.data?.length ? configObj.data : null]) // eslint-disable-line
653
-
654
- useEffect(() => {
655
- reloadURLData()
656
- }, [JSON.stringify(config.filters)])
657
-
658
- /**
659
- * When cove has a config and container ref publish the cove_loaded event.
660
- */
661
- useEffect(() => {
662
- if (container && !isEmpty(config) && !coveLoadedEventRan) {
663
- publish('cove_loaded', { config: config })
664
- setCoveLoadedEventRan(true)
665
- }
666
- }, [container, config]) // eslint-disable-line
667
-
668
- /**
669
- * Handles filter change events outside of COVE
670
- * Updates externalFilters state
671
- * Another useEffect listens to externalFilterChanges and updates the config.
672
- */
673
- useEffect(() => {
674
- const handleFilterData = e => {
675
- let tmp: any[] = []
676
- tmp.push(e.detail)
677
- setExternalFilters(tmp)
678
- }
52
+ if (!isUpdateNeeded) return
679
53
 
680
- subscribe('cove_filterData', e => handleFilterData(e))
54
+ const finalUrl = `${dataUrl.origin}${dataUrl.pathname}${new URLSearchParams(qsParams)}`
55
+ const ext = getFileExtension(finalUrl)
56
+ const data = await fetchAndParseData(finalUrl, ext)
681
57
 
682
- return () => {
683
- unsubscribe('cove_filterData', handleFilterData)
58
+ setConfig(prev => ({
59
+ ...prev,
60
+ data: { ...data, urlFiltered: true },
61
+ runtimeDataUrl: finalUrl,
62
+ formattedData: { ...data, urlFiltered: true }
63
+ }))
684
64
  }
685
65
  }, [config])
686
66
 
687
- /**
688
- * Handles changes to externalFilters
689
- * For some reason e.detail is returning [order: "asc"] even though
690
- * we're not passing that in. The code here checks for an active prop instead of an empty array.
691
- */
692
67
  useEffect(() => {
693
- if (externalFilters && externalFilters[0]) {
694
- const hasActiveProperty = externalFilters[0].hasOwnProperty('active')
695
-
696
- if (!hasActiveProperty) {
697
- let configCopy = { ...config }
698
- delete configCopy['filters']
699
- setConfig(configCopy)
700
- setFilteredData(filterVizData(externalFilters, excludedData))
701
- }
702
- }
703
-
704
- if (
705
- externalFilters &&
706
- externalFilters.length > 0 &&
707
- externalFilters.length > 0 &&
708
- externalFilters[0].hasOwnProperty('active')
709
- ) {
710
- let newConfigHere = { ...config, filters: externalFilters }
711
- setConfig(newConfigHere)
712
- setFilteredData(filterVizData(externalFilters, excludedData))
713
- }
714
- }, [externalFilters]) // eslint-disable-line
715
-
716
- // This will set the bump chart's default scaling type to date-time
717
- useEffect(() => {
718
- if (['Bump Chart'].includes(config.visualizationType)) {
719
- setConfig({
720
- ...config,
721
- xAxis: {
722
- ...config.xAxis,
723
- type: 'date-time'
724
- }
725
- })
726
- }
727
- }, [config.visualizationType])
728
-
729
- // Generates color palette to pass to child chart component
730
- useEffect(() => {
731
- if (stateData && config.xAxis && config.runtime?.seriesKeys) {
732
- const configPalette = ['Paired Bar', 'Deviation Bar'].includes(config.visualizationType)
733
- ? config.twoColor.palette
734
- : config.palette
735
- const allPalettes: Record<string, string[]> = { ...colorPalettes, ...twoColorPalette }
736
- let palette = config.customColors || allPalettes[configPalette]
737
- let numberOfKeys = config.runtime.seriesKeys.length
738
- let newColorScale
739
-
740
- while (numberOfKeys > palette.length) {
741
- palette = palette.concat(palette)
742
- }
743
-
744
- palette = palette.slice(0, numberOfKeys)
68
+ const load = async () => {
69
+ setIsLoading(true)
70
+ try {
71
+ const loadedConfig = await loadConfig(configUrl)
72
+ const data = await loadDataFromConfig(loadedConfig)
745
73
 
746
- newColorScale = () =>
747
- scaleOrdinal({
748
- domain: config.runtime.seriesLabelsAll,
749
- range: palette,
750
- unknown: null
74
+ setConfig({
75
+ ...loadedConfig,
76
+ data
751
77
  })
752
-
753
- setColorScale(newColorScale)
754
- setLoading(false)
755
- }
756
-
757
- if (config && stateData && config.sortData) {
758
- stateData.sort(sortData)
759
- }
760
- }, [config, stateData]) // eslint-disable-line
761
-
762
- // Called on legend click, highlights/unhighlights the data series with the given label
763
- const highlight = (label: Label) => {
764
- // If we're highlighting all the series, reset them
765
- if (seriesHighlight.length + 1 === config.runtime.seriesKeys.length && config.visualizationType !== 'Forecasting') {
766
- highlightReset()
767
- return
768
- }
769
-
770
- const newSeriesHighlight = [...seriesHighlight]
771
-
772
- let newHighlight = label.datum
773
- if (config.runtime.seriesLabels) {
774
- config.runtime.seriesKeys.forEach(key => {
775
- if (config.runtime.seriesLabels[key] === label.datum) {
776
- newHighlight = key
777
- }
778
- })
779
- }
780
-
781
- if (newSeriesHighlight.indexOf(newHighlight) !== -1) {
782
- newSeriesHighlight.splice(newSeriesHighlight.indexOf(newHighlight), 1)
783
- } else {
784
- newSeriesHighlight.push(newHighlight)
785
- }
786
-
787
- /**
788
- * pushDataKeyBySeriesName
789
- * - pushes series.dataKey into the series highlight based on the found series.name
790
- * @param {String} value
791
- */
792
- // const pushDataKeyBySeriesName = value => {
793
- // let matchingSeries = config.series.filter(series => series.name === value.text)
794
- // if (matchingSeries?.length > 0) {
795
- // newSeriesHighlight.push(matchingSeries[0].dataKey)
796
- // }
797
- // }
798
-
799
- // pushDataKeyBySeriesName(label)
800
-
801
- setSeriesHighlight(newSeriesHighlight)
802
- }
803
-
804
- // Called on reset button click, unhighlights all data series
805
- const highlightReset = () => {
806
- try {
807
- const legend = legendRef.current
808
- if (!legend) throw new Error('No legend available to set previous focus on.')
809
- legend.focus()
810
- } catch (e) {
811
- console.error('COVE:', e.message)
812
- }
813
- setSeriesHighlight([])
814
- }
815
-
816
- const section = config.orientation === 'horizontal' ? 'yAxis' : 'xAxis'
817
-
818
- const parseDate = (dateString, showError = true) => {
819
- let date = timeParse(config.runtime[section].dateParseFormat)(dateString)
820
- if (!date) {
821
- if (showError) {
822
- config.runtime.editorErrorMessage = `Error parsing date "${dateString}". Try reviewing your data and date parse settings in the X Axis section.`
823
- }
824
- return new Date()
825
- } else {
826
- return date
827
- }
828
- }
829
-
830
- const formatDate = (date, i, ticks) => {
831
- let formattedDate = timeFormat(config.runtime[section].dateDisplayFormat)(date)
832
- // Handle the case where all months work with '%b.' except for May
833
- if (config.runtime[section].dateDisplayFormat?.includes('%b.') && formattedDate.includes('May.')) {
834
- formattedDate = formattedDate.replace(/May\./g, 'May')
835
- }
836
- // Show years only once
837
- if (config.xAxis.showYearsOnce && config.runtime[section].dateDisplayFormat?.includes('%Y') && ticks) {
838
- const prevDate = ticks[i - 1] ? ticks[i - 1].value : null
839
- const prevFormattedDate = timeFormat(config.runtime[section].dateDisplayFormat)(prevDate)
840
- const year = formattedDate.match(/\d{4}/)
841
- const prevYear = prevFormattedDate.match(/\d{4}/)
842
- if (year && prevYear && year[0] === prevYear[0]) {
843
- formattedDate = formattedDate.replace(year, '')
844
- }
845
- }
846
- return formattedDate
847
- }
848
-
849
- const formatTooltipsDate = date => {
850
- return timeFormat(config.tooltips.dateDisplayFormat)(date)
851
- }
852
-
853
- // Format numeric data based on settings in config OR from passed in settings for Additional Columns
854
- // - use only for old horizontal data - newer formatNumber is in helper/formatNumber
855
- // TODO: we should combine various formatNumber functions across this project.
856
- // TODO suggestion: pass all options as object key/values to allow for more flexibility
857
- const formatNumber = (
858
- num,
859
- axis,
860
- shouldAbbreviate = false,
861
- addColPrefix,
862
- addColSuffix,
863
- addColRoundTo,
864
- { index, length } = { index: null, length: null }
865
- ) => {
866
- // if num is NaN return num
867
- if (isNaN(num) || !num) return num
868
- // Check if the input number is negative
869
- const isNegative = num < 0
870
-
871
- if (axis === undefined || !axis) axis = 'left'
872
-
873
- // If the input number is negative, take the absolute value
874
- if (isNegative) {
875
- num = Math.abs(num)
876
- }
877
-
878
- // destructure dataFormat values
879
- let {
880
- dataFormat: {
881
- commas,
882
- abbreviated,
883
- roundTo,
884
- prefix,
885
- suffix,
886
- rightRoundTo,
887
- bottomRoundTo,
888
- rightPrefix,
889
- rightSuffix,
890
- bottomPrefix,
891
- bottomSuffix,
892
- bottomAbbreviated,
893
- onlyShowTopPrefixSuffix
894
- }
895
- } = config
896
-
897
- // check if value contains comma and remove it. later will add comma below.
898
- if (String(num).indexOf(',') !== -1) num = num.replaceAll(',', '')
899
-
900
- let original = num
901
- let stringFormattingOptions: any = {
902
- useGrouping: commas ? true : false // for old chart data table to work right cant just leave this to undefined
903
- }
904
- if (axis === 'left' || axis === undefined) {
905
- let roundToPlace
906
- if (addColRoundTo !== undefined) {
907
- // if its an Additional Column
908
- roundToPlace = addColRoundTo ? Number(addColRoundTo) : 0
909
- } else {
910
- roundToPlace = roundTo ? Number(roundTo) : 0
911
- }
912
- stringFormattingOptions = {
913
- useGrouping: addColRoundTo ? true : config.dataFormat.commas ? true : false,
914
- minimumFractionDigits: roundToPlace,
915
- maximumFractionDigits: roundToPlace
916
- }
917
- }
918
-
919
- if (axis === 'right') {
920
- stringFormattingOptions = {
921
- useGrouping: config.dataFormat.rightCommas ? true : false,
922
- minimumFractionDigits: rightRoundTo ? Number(rightRoundTo) : 0,
923
- maximumFractionDigits: rightRoundTo ? Number(rightRoundTo) : 0
924
- }
925
- }
926
-
927
- const resolveBottomTickRounding = () => {
928
- if (config.forestPlot.type === 'Logarithmic' && !bottomRoundTo) return 2
929
- if (Number(bottomRoundTo)) return Number(bottomRoundTo)
930
- return 0
931
- }
932
-
933
- if (axis === 'bottom') {
934
- stringFormattingOptions = {
935
- useGrouping: config.dataFormat.bottomCommas ? true : false,
936
- minimumFractionDigits: resolveBottomTickRounding(),
937
- maximumFractionDigits: resolveBottomTickRounding()
938
- }
939
- }
940
-
941
- num = numberFromString(num)
942
-
943
- if (isNaN(num)) {
944
- config.runtime.editorErrorMessage = `Unable to parse number from data ${original}. Try reviewing your data and selections in the Data Series section.`
945
- return original
946
- }
947
-
948
- if (!config.dataFormat) return num
949
- if (config.dataCutoff) {
950
- let cutoff = numberFromString(config.dataCutoff)
951
-
952
- if (num < cutoff) {
953
- num = cutoff
954
- }
955
- }
956
-
957
- // When we're formatting the left axis
958
- // Use commas also updates bars and the data table
959
- // We can't use commas when we're formatting the dataFormatted number
960
- // Example: commas -> 12,000; abbreviated -> 12k (correct); abbreviated & commas -> 12 (incorrect)
961
- //
962
- // Edge case for small numbers with decimals
963
- // - if roundTo undefined which means it is blank, then do not round
964
-
965
- if (
966
- (axis === 'left' && commas && abbreviated && shouldAbbreviate) ||
967
- (axis === 'bottom' && commas && abbreviated && shouldAbbreviate)
968
- ) {
969
- num = num // eslint-disable-line
970
- } else {
971
- num = num.toLocaleString('en-US', stringFormattingOptions)
972
- }
973
- let result = ''
974
-
975
- if (abbreviated && axis === 'left' && shouldAbbreviate) {
976
- num = abbreviateNumber(parseFloat(num))
977
- }
978
-
979
- if (bottomAbbreviated && axis === 'bottom' && shouldAbbreviate) {
980
- num = abbreviateNumber(parseFloat(num))
981
- }
982
-
983
- if (addColPrefix && axis === 'left') {
984
- result = addColPrefix + result
985
- } else {
986
- // if onlyShowTopPrefixSuffix only show top prefix
987
- const suppressAllButLast = onlyShowTopPrefixSuffix && length - 1 !== index
988
- if (prefix && axis === 'left' && !suppressAllButLast) {
989
- result += prefix
990
- }
991
- }
992
-
993
- if (rightPrefix && axis === 'right') {
994
- result += rightPrefix
995
- }
996
-
997
- if (bottomPrefix && axis === 'bottom') {
998
- result += bottomPrefix
999
- }
1000
-
1001
- // combine prefix and num
1002
- result += num
1003
-
1004
- if (addColSuffix && axis === 'left') {
1005
- result += addColSuffix
1006
- } else {
1007
- if (suffix && axis === 'left' && !onlyShowTopPrefixSuffix) {
1008
- result += suffix
1009
- }
1010
- }
1011
-
1012
- if (rightSuffix && axis === 'right') {
1013
- result += rightSuffix
1014
- }
1015
-
1016
- if (bottomSuffix && axis === 'bottom') {
1017
- result += bottomSuffix
1018
- }
1019
- if (isNegative) {
1020
- result = '-' + result
1021
- }
1022
-
1023
- return String(result)
1024
- }
1025
-
1026
- const missingRequiredSections = () => {
1027
- if (config.visualizationType === 'Sankey') return false // skip checks for now
1028
- if (config.visualizationType === 'Forecasting') return false // skip required checks for now.
1029
- if (config.visualizationType === 'Forest Plot') return false // skip required checks for now.
1030
- if (config.visualizationType === 'Pie') {
1031
- if (undefined === config?.yAxis.dataKey) {
1032
- return true
1033
- }
1034
- } else {
1035
- if ((undefined === config?.series || false === config?.series.length > 0) && !config?.dynamicSeries) {
1036
- return true
1037
- }
1038
- }
1039
-
1040
- if (!config.xAxis.dataKey) {
1041
- return true
1042
- }
1043
-
1044
- return false
1045
- }
1046
-
1047
- // used for Additional Column
1048
- const displayDataAsText = (value, columnName) => {
1049
- if (value === null || value === '' || value === undefined) {
1050
- return ''
1051
- }
1052
-
1053
- if (typeof value === 'string' && value.length > 0 && config.legend.type === 'equalnumber') {
1054
- return value
1055
- }
1056
-
1057
- let formattedValue = value
1058
-
1059
- let columnObj //= config.columns[columnName]
1060
- // config.columns not an array but a hash of objects
1061
- if (Object.keys(config.columns).length > 0) {
1062
- Object.keys(config.columns).forEach(function (key) {
1063
- var column = config.columns[key]
1064
- // add if not the index AND it is enabled to be added to data table
1065
- if (column.name === columnName) {
1066
- columnObj = column
1067
- }
1068
- })
1069
- }
1070
-
1071
- if (columnObj === undefined) {
1072
- // then use left axis config
1073
- columnObj = config.type === 'chart' ? config.dataFormat : config.primary
1074
- // NOTE: Left Value Axis uses different names
1075
- // so map them below so the code below works
1076
- // - copy commas to useCommas to work below
1077
- columnObj['useCommas'] = columnObj.commas
1078
- // - copy roundTo to roundToPlace to work below
1079
- columnObj['roundToPlace'] = columnObj.roundTo ? columnObj.roundTo : ''
1080
- }
1081
-
1082
- if (columnObj) {
1083
- // If value is a number, apply specific formattings
1084
- let hasDecimal = false
1085
- let decimalPoint = 0
1086
- if (Number(value)) {
1087
- if (columnObj.roundToPlace >= 0) {
1088
- hasDecimal = columnObj.roundToPlace ? columnObj.roundToPlace !== '' || columnObj.roundToPlace !== null : false
1089
- decimalPoint = columnObj.roundToPlace ? Number(columnObj.roundToPlace) : 0
1090
-
1091
- // Rounding
1092
- if (columnObj.hasOwnProperty('roundToPlace') && hasDecimal) {
1093
- formattedValue = Number(value).toFixed(decimalPoint)
1094
- }
1095
- }
1096
-
1097
- if (columnObj.hasOwnProperty('useCommas') && columnObj.useCommas === true) {
1098
- // Formats number to string with commas - allows up to 5 decimal places, if rounding is not defined.
1099
- // Otherwise, uses the rounding value set at 'columnObj.roundToPlace'.
1100
- formattedValue = Number(value).toLocaleString('en-US', {
1101
- style: 'decimal',
1102
- minimumFractionDigits: hasDecimal ? decimalPoint : 0,
1103
- maximumFractionDigits: hasDecimal ? decimalPoint : 5
1104
- })
1105
- }
78
+ } catch (error) {
79
+ console.error('Failed to load configuration or data', error)
80
+ } finally {
81
+ setIsLoading(false)
1106
82
  }
1107
-
1108
- // add prefix and suffix if set
1109
- formattedValue = (columnObj.prefix || '') + formattedValue + (columnObj.suffix || '')
1110
83
  }
1111
84
 
1112
- return formattedValue
1113
- }
1114
-
1115
- const Confirm = () => {
1116
- const confirmDone = e => {
1117
- if (e) {
1118
- e.preventDefault()
1119
- }
1120
-
1121
- let newConfig = { ...config }
1122
- delete newConfig.newViz
85
+ load()
86
+ }, [configUrl, loadConfig])
1123
87
 
1124
- updateConfig(newConfig)
1125
- }
1126
-
1127
- const styles = {
1128
- position: 'relative',
1129
- height: '100vh',
1130
- width: '100%',
1131
- display: 'flex',
1132
- justifyContent: 'center',
1133
- alignItems: 'center',
1134
- gridArea: 'content'
1135
- }
1136
-
1137
- return (
1138
- <section className='waiting' style={styles}>
1139
- <section className='waiting-container'>
1140
- <h3>Finish Configuring</h3>
1141
- <p>Set all required options to the left and confirm below to display a preview of the chart.</p>
1142
- <Button
1143
- className='btn btn-primary'
1144
- style={{ margin: '1em auto' }}
1145
- disabled={missingRequiredSections()}
1146
- onClick={e => confirmDone(e)}
1147
- >
1148
- I'm Done
1149
- </Button>
1150
- </section>
1151
- </section>
1152
- )
1153
- }
1154
-
1155
- const Error = () => {
1156
- const styles = {
1157
- position: 'absolute',
1158
- background: 'white',
1159
- zIndex: '999',
1160
- height: '100vh',
1161
- width: '100%',
1162
- display: 'flex',
1163
- justifyContent: 'center',
1164
- alignItems: 'center',
1165
- gridArea: 'content'
1166
- }
1167
- return (
1168
- <section className='waiting' style={styles}>
1169
- <section className='waiting-container'>
1170
- <h3>Error With Configuration</h3>
1171
- <p>{config.runtime.editorErrorMessage}</p>
1172
- </section>
1173
- </section>
1174
- )
1175
- }
1176
-
1177
- // this is passed DOWN into the various components
1178
- // then they do a lookup based on the bin number as index into here (TT)
1179
- const applyLegendToRow = rowObj => {
1180
- try {
1181
- if (!rowObj) throw new Error('COVE: No rowObj in applyLegendToRow')
1182
- // Navigation map
1183
- if ('navigation' === config.type) {
1184
- let mapColorPalette = colorPalettes[config.color] || colorPalettes['bluegreenreverse']
1185
- return generateColorsArray(mapColorPalette[3])
1186
- }
1187
-
1188
- // Fail state
1189
- return generateColorsArray()
1190
- } catch (e) {
1191
- console.error('COVE: ', e) // eslint-disable-line
88
+ useEffect(() => {
89
+ if (!_.isEqual(prevFiltersRef.current, config.filters)) {
90
+ prevFiltersRef.current = config.filters
91
+ reloadFilteredData()
1192
92
  }
1193
- }
93
+ }, [config.filters, reloadFilteredData])
1194
94
 
1195
- // TODO: should be part of the DataTransform class.
1196
- const clean = data => {
1197
- // cleaning is deleting data we need in forecasting charts.
1198
- if (!Array.isArray(data)) return []
1199
- if (config.visualizationType === 'Forecasting') return data
1200
- if (config.series?.some(series => !!series.dynamicCategory)) return data
1201
- return config?.xAxis?.dataKey ? transform.cleanData(data, config.xAxis.dataKey) : data
1202
- }
95
+ if (isLoading) return <Loading />
1203
96
 
1204
- const getTableRuntimeData = () => {
1205
- if (visualizationType === 'Sankey') return config?.data?.[0]?.tableData
1206
- const data = filteredData || excludedData
1207
- const dynamicSeries = config.series.find(series => !!series.dynamicCategory)
1208
- if (!dynamicSeries) return data
1209
- const usedColumns = Object.values(config.columns)
1210
- .filter(col => col.dataTable)
1211
- .map(col => col.name)
1212
- .concat([dynamicSeries.dynamicCategory, dynamicSeries.dataKey])
1213
- if (config.xAxis?.dataKey) usedColumns.push(config.xAxis.dataKey)
1214
- return data.map(d => _.pick(d, usedColumns))
1215
- }
97
+ return <CdcChart config={config} isEditor={isEditor} isDebug={isDebug} />
98
+ }
1216
99
 
1217
- const pivotDynamicSeries = (config: ChartConfig): TableConfig => {
1218
- const tableConfig: TableConfig = _.cloneDeep(config)
1219
- const dynamicSeries = tableConfig.series.find(series => !!series.dynamicCategory)
1220
- if (dynamicSeries) {
1221
- const pivot: Pivot = { columnName: dynamicSeries.dynamicCategory, valueColumns: [dynamicSeries.dataKey] }
1222
- tableConfig.table.pivot = pivot
1223
- }
1224
- return tableConfig
1225
- }
100
+ export default CdcChartWrapper
1226
101
 
1227
- // Prevent render if loading
1228
- let body = <Loading />
102
+ const parseCsv = (responseText: string, delimiter = '|') => {
103
+ const sanitizedText = responseText.replace(/(".*?")|,/g, (...m) => m[1] || delimiter).replace(/["]+/g, '')
1229
104
 
1230
- const makeClassName = string => {
1231
- if (!string || !string.toLowerCase) return
1232
- return string.toLowerCase().replaceAll(/ /g, '-')
1233
- }
105
+ return Papa.parse(sanitizedText, {
106
+ header: true,
107
+ dynamicTyping: true,
108
+ skipEmptyLines: true,
109
+ delimiter
110
+ }).data
111
+ }
1234
112
 
1235
- const getChartWrapperClasses = () => {
1236
- const isLegendOnBottom = legend?.position === 'bottom' || isLegendWrapViewport(currentViewport)
1237
- const classes = ['chart-container', 'p-relative']
1238
- if (legend?.position) {
1239
- if (isLegendWrapViewport(currentViewport) && legend?.position !== 'top') {
1240
- classes.push('legend-bottom')
1241
- } else {
1242
- classes.push(`legend-${legend.position}`)
1243
- }
113
+ const fetchAndParseData = async (url: string, ext: string) => {
114
+ try {
115
+ const response = await fetch(url)
116
+ if (ext === 'csv' || isSolrCsv(url)) {
117
+ const responseText = await response.text()
118
+ return parseCsv(responseText)
119
+ } else if (ext === 'json' || isSolrJson(url)) {
120
+ return await response.json()
1244
121
  }
1245
- if (legend?.hide) classes.push('legend-hidden')
1246
- if (lineDatapointClass) classes.push(lineDatapointClass)
1247
- if (!config.barHasBorder) classes.push('chart-bar--no-border')
1248
- if (config.brush?.active && dashboardConfig?.type === 'dashboard' && (!isLegendOnBottom || legend.hide))
1249
- classes.push('dashboard-brush')
1250
- classes.push(...contentClasses)
1251
- return classes
122
+ } catch (error) {
123
+ console.error(`Error parsing URL: ${url}`, error)
124
+ return []
1252
125
  }
126
+ }
1253
127
 
1254
- const getChartSubTextClasses = () => {
1255
- const classes = ['subtext ']
1256
- const isLegendOnBottom = legend?.position === 'bottom' || isLegendWrapViewport(currentViewport)
1257
-
1258
- if (config.isResponsiveTicks) classes.push('subtext--responsive-ticks ')
1259
- if (config.brush?.active && !isLegendOnBottom) classes.push('subtext--brush-active ')
1260
- if (config.brush?.active && config.legend.hide) classes.push('subtext--brush-active ')
1261
- return classes
1262
- }
1263
-
1264
- if (!loading) {
1265
- const tableLink = (
1266
- <a href={`#data-table-${config.dataKey}`} className='margin-left-href'>
1267
- {config.dataKey} (Go to Table)
1268
- </a>
1269
- )
1270
- body = (
1271
- <>
1272
- {isEditor && <EditorPanel />}
1273
- <Layout.Responsive isEditor={isEditor}>
1274
- {config.newViz && <Confirm />}
1275
- {undefined === config.newViz && isEditor && config.runtime && config.runtime?.editorErrorMessage && <Error />}
1276
- {!missingRequiredSections() && !config.newViz && (
1277
- <div
1278
- className={`cdc-chart-inner-container cove-component__content type-${makeClassName(
1279
- config.visualizationType
1280
- )}`}
1281
- aria-label={handleChartAriaLabels(config)}
1282
- tabIndex={0}
1283
- >
1284
- <Title
1285
- showTitle={config.showTitle}
1286
- isDashboard={isDashboard}
1287
- title={title}
1288
- superTitle={config.superTitle}
1289
- classes={['chart-title', `${config.theme}`, 'cove-component__header']}
1290
- style={undefined}
1291
- />
1292
-
1293
- {/* Visualization Wrapper */}
1294
- <div className={getChartWrapperClasses().join(' ')}>
1295
- {/* Intro Text/Message */}
1296
- {config?.introText && config.visualizationType !== 'Spark Line' && (
1297
- <section className={`introText `}>{parse(config.introText)}</section>
1298
- )}
1299
-
1300
- {/* Filters */}
1301
- {config.filters && !externalFilters && config.visualizationType !== 'Spark Line' && (
1302
- <Filters
1303
- config={config}
1304
- setConfig={setConfig}
1305
- setFilteredData={setFilteredData}
1306
- filteredData={filteredData}
1307
- excludedData={excludedData}
1308
- filterData={filterVizData}
1309
- dimensions={dimensions}
1310
- />
1311
- )}
1312
- <SkipTo skipId={handleChartTabbing(config, legendId)} skipMessage='Skip Over Chart Container' />
1313
- {config.annotations?.length > 0 && (
1314
- <SkipTo
1315
- skipId={handleChartTabbing(config, legendId)}
1316
- skipMessage={`Skip over annotations`}
1317
- key={`skip-annotations`}
1318
- />
1319
- )}
1320
- <LegendWrapper>
1321
- <div
1322
- className={
1323
- legend.hide || isLegendWrapViewport(currentViewport)
1324
- ? 'w-100'
1325
- : legend.position === 'bottom' || legend.position === 'top' || visualizationType === 'Sankey'
1326
- ? 'w-100'
1327
- : 'w-75'
1328
- }
1329
- >
1330
- {/* All charts with LinearChart */}
1331
- {!['Spark Line', 'Line', 'Sankey', 'Pie', 'Sankey'].includes(config.visualizationType) && (
1332
- <div ref={parentRef} style={{ width: `100%` }}>
1333
- <ParentSize>
1334
- {parent => (
1335
- <LinearChart ref={svgRef} parentWidth={parent.width} parentHeight={parent.height} />
1336
- )}
1337
- </ParentSize>
1338
- </div>
1339
- )}
1340
-
1341
- {config.visualizationType === 'Pie' && (
1342
- <ParentSize className='justify-content-center d-flex' style={{ width: `100%` }}>
1343
- {parent => <PieChart ref={svgRef} parentWidth={parent.width} parentHeight={parent.height} />}
1344
- </ParentSize>
1345
- )}
1346
- {/* Line Chart */}
1347
- {config.visualizationType === 'Line' &&
1348
- (checkLineToBarGraph() ? (
1349
- <div ref={parentRef} style={{ width: `100%` }}>
1350
- <ParentSize>
1351
- {parent => (
1352
- <LinearChart ref={svgRef} parentWidth={parent.width} parentHeight={parent.height} />
1353
- )}
1354
- </ParentSize>
1355
- </div>
1356
- ) : (
1357
- <div ref={parentRef} style={{ width: `100%` }}>
1358
- <ParentSize>
1359
- {parent => (
1360
- <LinearChart ref={svgRef} parentWidth={parent.width} parentHeight={parent.height} />
1361
- )}
1362
- </ParentSize>
1363
- </div>
1364
- ))}
1365
- {/* Sparkline */}
1366
- {config.visualizationType === 'Spark Line' && (
1367
- <>
1368
- <Filters
1369
- config={config}
1370
- setConfig={setConfig}
1371
- setFilteredData={setFilteredData}
1372
- filteredData={filteredData}
1373
- excludedData={excludedData}
1374
- filterData={filterVizData}
1375
- dimensions={dimensions}
1376
- />
1377
- {config?.introText && (
1378
- <section className='introText' style={{ padding: '0px 0 35px' }}>
1379
- {parse(config.introText)}
1380
- </section>
1381
- )}
1382
- <div style={{ height: `100px`, width: `100%`, ...sparkLineStyles }}>
1383
- <ParentSize>{parent => <SparkLine width={parent.width} height={parent.height} />}</ParentSize>
1384
- </div>
1385
- {description && (
1386
- <div className='subtext' style={{ padding: '35px 0 15px' }}>
1387
- {parse(description)}
1388
- </div>
1389
- )}
1390
- </>
1391
- )}
1392
- {/* Sankey */}
1393
- {config.visualizationType === 'Sankey' && (
1394
- <ParentSize aria-hidden='true'>
1395
- {parent => <SankeyChart runtime={config.runtime} width={parent.width} height={parent.height} />}
1396
- </ParentSize>
1397
- )}
1398
- </div>
1399
- {/* Legend */}
1400
- {!config.legend.hide &&
1401
- config.visualizationType !== 'Spark Line' &&
1402
- config.visualizationType !== 'Sankey' && (
1403
- <Legend ref={legendRef} skipId={handleChartTabbing(config, legendId)} />
1404
- )}
1405
- </LegendWrapper>
1406
- {/* Link */}
1407
- {isDashboard && config.table && config.table.show && config.table.showDataTableLink
1408
- ? tableLink
1409
- : link && link}
1410
- {/* Description */}
1411
-
1412
- {description && config.visualizationType !== 'Spark Line' && (
1413
- <div className={getChartSubTextClasses().join('')}>{parse(description)}</div>
1414
- )}
1415
-
1416
- {/* buttons */}
1417
- <MediaControls.Section classes={['download-buttons']}>
1418
- {config.table.showDownloadImgButton && (
1419
- <MediaControls.Button
1420
- text='Download Image'
1421
- title='Download Chart as Image'
1422
- type='image'
1423
- state={config}
1424
- elementToCapture={imageId}
1425
- />
1426
- )}
1427
- {config.table.showDownloadPdfButton && (
1428
- <MediaControls.Button
1429
- text='Download PDF'
1430
- title='Download Chart as PDF'
1431
- type='pdf'
1432
- state={config}
1433
- elementToCapture={imageId}
1434
- />
1435
- )}
1436
- </MediaControls.Section>
1437
- {/* Data Table */}
1438
- {((config.xAxis.dataKey &&
1439
- config.table.show &&
1440
- config.visualizationType !== 'Spark Line' &&
1441
- config.visualizationType !== 'Sankey') ||
1442
- (config.visualizationType === 'Sankey' && config.table.show)) && (
1443
- <DataTable
1444
- config={pivotDynamicSeries(config)}
1445
- rawData={
1446
- config.visualizationType === 'Sankey'
1447
- ? config?.data?.[0]?.tableData
1448
- : config.table.customTableConfig
1449
- ? filterVizData(config.filters, config.data)
1450
- : config.data
1451
- }
1452
- runtimeData={getTableRuntimeData()}
1453
- expandDataTable={config.table.expanded}
1454
- columns={config.columns}
1455
- displayDataAsText={displayDataAsText}
1456
- displayGeoName={name => name}
1457
- applyLegendToRow={applyLegendToRow}
1458
- tableTitle={config.table.label}
1459
- indexTitle={config.table.indexLabel}
1460
- vizTitle={title}
1461
- viewport={currentViewport}
1462
- tabbingId={handleChartTabbing(config, legendId)}
1463
- colorScale={colorScale}
1464
- />
1465
- )}
1466
- {config?.annotations?.length > 0 && <Annotation.Dropdown />}
1467
- {/* show pdf or image button */}
1468
- </div>
1469
- {config?.footnotes && <section className='footnotes'>{parse(config.footnotes)}</section>}
1470
- </div>
1471
- )}
1472
- </Layout.Responsive>
1473
- </>
1474
- )
128
+ const loadDataFromConfig = async (response: any) => {
129
+ if (!response.dataUrl || _.some(_.get(response, 'filters', []), { type: 'url' })) {
130
+ return response.data || []
1475
131
  }
1476
132
 
1477
- const getXAxisData = d =>
1478
- isDateScale(config.runtime.xAxis)
1479
- ? parseDate(d[config.runtime.originalXAxis.dataKey]).getTime()
1480
- : d[config.runtime.originalXAxis.dataKey]
1481
- const getYAxisData = (d, seriesKey) => d[seriesKey]
133
+ const ext = getFileExtension(response.dataUrl)
134
+ const urlWithCacheBuster = `${response.dataUrl}`
1482
135
 
1483
- const capitalize = str => {
1484
- return str.charAt(0).toUpperCase() + str.slice(1)
136
+ try {
137
+ return await fetchAndParseData(urlWithCacheBuster, ext)
138
+ } catch {
139
+ console.error(`Cannot parse URL: ${response.dataUrl}`)
140
+ return []
1485
141
  }
1486
-
1487
- const contextValues = {
1488
- brushConfig,
1489
- capitalize,
1490
- clean,
1491
- colorPalettes,
1492
- colorScale,
1493
- config,
1494
- currentViewport,
1495
- dashboardConfig,
1496
- debugSvg: isDebug,
1497
- dimensions,
1498
- dynamicLegendItems,
1499
- excludedData,
1500
- formatDate,
1501
- formatNumber,
1502
- formatTooltipsDate,
1503
- getXAxisData,
1504
- getYAxisData,
1505
- handleChartAriaLabels,
1506
- handleLineType,
1507
- handleChartTabbing,
1508
- highlight,
1509
- highlightReset,
1510
- imageId,
1511
- isDashboard,
1512
- isLegendBottom: legend?.position === 'bottom' || isLegendWrapViewport(currentViewport),
1513
- isDebug,
1514
- isDraggingAnnotation,
1515
- handleDragStateChange,
1516
- isEditor,
1517
- isNumber,
1518
- legend,
1519
- legendId,
1520
- legendRef,
1521
- lineOptions,
1522
- loading,
1523
- missingRequiredSections,
1524
- outerContainerRef,
1525
- parentRef,
1526
- parseDate,
1527
- rawData: _.cloneDeep(stateData) ?? {},
1528
- seriesHighlight,
1529
- setBrushConfig,
1530
- setConfig,
1531
- setDynamicLegendItems,
1532
- setEditing,
1533
- setFilteredData,
1534
- setParentConfig,
1535
- setSeriesHighlight,
1536
- setSharedFilter,
1537
- setSharedFilterValue,
1538
- svgRef,
1539
- tableData: filteredData || excludedData, // do not clean table data
1540
- transformedData: clean(filteredData || excludedData), // do this right before passing to components
1541
- twoColorPalette,
1542
- unfilteredData: _.cloneDeep(stateData),
1543
- updateConfig
1544
- }
1545
-
1546
- return (
1547
- <ConfigContext.Provider value={contextValues}>
1548
- <Layout.VisualizationWrapper
1549
- config={config}
1550
- isEditor={isEditor}
1551
- currentViewport={currentViewport}
1552
- ref={outerContainerRef}
1553
- imageId={imageId}
1554
- showEditorPanel={config?.showEditorPanel}
1555
- >
1556
- {body}
1557
- </Layout.VisualizationWrapper>
1558
- </ConfigContext.Provider>
1559
- )
1560
142
  }
1561
-
1562
- export default CdcChart