@cdc/chart 1.3.1 → 1.3.3

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 (56) hide show
  1. package/dist/cdcchart.js +85 -0
  2. package/examples/age-adjusted-rates.json +1218 -0
  3. package/examples/case-rate-example-config.json +36 -0
  4. package/examples/case-rate-example-data.json +33602 -0
  5. package/examples/covid-confidence-example-config.json +35 -0
  6. package/examples/covid-example-config.json +36 -0
  7. package/examples/covid-example-data-confidence.json +32 -0
  8. package/examples/covid-example-data.json +22 -0
  9. package/examples/cutoff-example-config.json +36 -0
  10. package/examples/cutoff-example-data.json +38 -0
  11. package/examples/date-exclusions-config.json +62 -0
  12. package/examples/date-exclusions-data.json +162 -0
  13. package/examples/horizontal-chart.json +35 -0
  14. package/examples/horizontal-stacked-bar-chart.json +36 -0
  15. package/examples/line-chart.json +76 -0
  16. package/examples/paired-bar-data.json +14 -0
  17. package/examples/paired-bar-example.json +48 -0
  18. package/examples/paired-bar-formatted.json +37 -0
  19. package/examples/planet-chart-horizontal-example-config.json +35 -0
  20. package/examples/planet-combo-example-config.json +31 -0
  21. package/examples/planet-example-config.json +35 -0
  22. package/examples/planet-example-data.json +56 -0
  23. package/examples/planet-pie-example-config.json +28 -0
  24. package/examples/private/newtest.csv +101 -0
  25. package/examples/private/test.json +10124 -0
  26. package/examples/temp-example-config.json +57 -0
  27. package/examples/temp-example-data.json +130 -0
  28. package/package.json +9 -8
  29. package/src/CdcChart.tsx +836 -0
  30. package/src/components/BarChart.tsx +571 -0
  31. package/src/components/BarStackVertical.js +0 -0
  32. package/src/components/DataTable.tsx +229 -0
  33. package/src/components/EditorPanel.js +1319 -0
  34. package/src/components/LineChart.tsx +76 -0
  35. package/src/components/LinearChart.tsx +459 -0
  36. package/src/components/PairedBarChart.tsx +144 -0
  37. package/src/components/PieChart.tsx +189 -0
  38. package/src/components/SparkLine.js +206 -0
  39. package/src/context.tsx +5 -0
  40. package/src/data/initial-state.js +61 -0
  41. package/src/hooks/useActiveElement.js +19 -0
  42. package/src/hooks/useColorPalette.ts +83 -0
  43. package/src/hooks/useReduceData.ts +43 -0
  44. package/src/images/active-checkmark.svg +1 -0
  45. package/src/images/asc.svg +1 -0
  46. package/src/images/desc.svg +1 -0
  47. package/src/images/inactive-checkmark.svg +1 -0
  48. package/src/images/warning.svg +1 -0
  49. package/src/index.html +68 -0
  50. package/src/index.tsx +21 -0
  51. package/src/scss/DataTable.scss +23 -0
  52. package/src/scss/LinearChart.scss +0 -0
  53. package/src/scss/editor-panel.scss +693 -0
  54. package/src/scss/main.scss +426 -0
  55. package/src/scss/mixins.scss +0 -0
  56. package/src/scss/variables.scss +1 -0
@@ -0,0 +1,836 @@
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+
3
+ // IE11
4
+ import 'core-js/stable'
5
+ import ResizeObserver from 'resize-observer-polyfill'
6
+ import 'whatwg-fetch'
7
+
8
+ import { LegendOrdinal, LegendItem, LegendLabel } from '@visx/legend';
9
+ import { scaleOrdinal } from '@visx/scale';
10
+ import ParentSize from '@visx/responsive/lib/components/ParentSize';
11
+
12
+ import { timeParse, timeFormat } from 'd3-time-format';
13
+ import Papa from 'papaparse';
14
+ import parse from 'html-react-parser';
15
+
16
+ import Loading from '@cdc/core/components/Loading';
17
+ import DataTransform from '@cdc/core/components/DataTransform';
18
+ import getViewport from '@cdc/core/helpers/getViewport';
19
+
20
+ import PieChart from './components/PieChart';
21
+ import LinearChart from './components/LinearChart';
22
+ import DataTable from './components/DataTable';
23
+ import Context from './context';
24
+ import defaults from './data/initial-state';
25
+
26
+ import EditorPanel from './components/EditorPanel';
27
+ import numberFromString from '@cdc/core/helpers/numberFromString'
28
+ import LegendCircle from '@cdc/core/components/LegendCircle';
29
+ import {colorPalettesChart as colorPalettes} from '../../core/data/colorPalettes';
30
+
31
+ import { publish, subscribe, unsubscribe } from '@cdc/core/helpers/events';
32
+
33
+ import SparkLine from './components/SparkLine';
34
+
35
+ import './scss/main.scss';
36
+
37
+ export default function CdcChart(
38
+ { configUrl, config: configObj, isEditor = false, isDashboard = false, setConfig: setParentConfig, setEditing, hostname} :
39
+ { configUrl?: string, config?: any, isEditor?: boolean, isDashboard?: boolean, setConfig?, setEditing?, hostname? }
40
+ ) {
41
+
42
+ const transform = new DataTransform();
43
+
44
+ interface keyable { [key: string]: any }
45
+
46
+ const [loading, setLoading] = useState<Boolean>(true);
47
+ const [colorScale, setColorScale] = useState<any>(null);
48
+ const [config, setConfig] = useState<keyable>({});
49
+ const [stateData, setStateData] = useState<Array<Object>>(config.data || []);
50
+ const [excludedData, setExcludedData] = useState<Array<Object>>();
51
+ const [filteredData, setFilteredData] = useState<Array<Object>>();
52
+ const [seriesHighlight, setSeriesHighlight] = useState<Array<String>>([]);
53
+ const [currentViewport, setCurrentViewport] = useState<String>('lg');
54
+ const [dimensions, setDimensions] = useState<Array<Number>>([]);
55
+ const [parentElement, setParentElement] = useState(false)
56
+ const [externalFilters, setExternalFilters] = useState([]);
57
+ const [container, setContainer] = useState()
58
+ const [coveLoadedEventRan, setCoveLoadedEventRan] = useState(false)
59
+
60
+ const legendGlyphSize = 15;
61
+ const legendGlyphSizeHalf = legendGlyphSize / 2;
62
+
63
+ const handleChartTabbing = config.showSidebar ? `#legend` : config?.title ? `#dataTableSection__${config.title.replace(/\s/g, '')}` : `#dataTableSection`
64
+
65
+ const cacheBustingString = () => {
66
+ const round = 1000 * 60 * 15;
67
+ const date = new Date();
68
+ return new Date(date.getTime() - (date.getTime() % round)).toISOString();
69
+ }
70
+ const loadConfig = async () => {
71
+ let response = configObj || await (await fetch(configUrl)).json();
72
+
73
+ // If data is included through a URL, fetch that and store
74
+ let data = response.formattedData || response.data || {};
75
+
76
+ if (response.dataUrl) {
77
+
78
+ try {
79
+ const regex = /(?:\.([^.]+))?$/
80
+
81
+ const ext = (regex.exec(response.dataUrl)[1])
82
+ if ('csv' === ext) {
83
+ data = await fetch(response.dataUrl + `?v=${cacheBustingString()}`)
84
+ .then(response => response.text())
85
+ .then(responseText => {
86
+ const parsedCsv = Papa.parse(responseText, {
87
+ header: true,
88
+ dynamicTyping: true,
89
+ skipEmptyLines: true
90
+ })
91
+ return parsedCsv.data
92
+ })
93
+ }
94
+
95
+ if ('json' === ext) {
96
+ data = await fetch(response.dataUrl + `?v=${cacheBustingString()}`)
97
+ .then(response => response.json())
98
+ }
99
+ } catch {
100
+ console.error(`Cannot parse URL: ${response.dataUrl}`);
101
+ data = [];
102
+ }
103
+
104
+ if(response.dataDescription) {
105
+ data = transform.autoStandardize(data);
106
+ data = transform.developerStandardize(data, response.dataDescription);
107
+ }
108
+ }
109
+
110
+ if(data) {
111
+ setStateData(data)
112
+ setExcludedData(data)
113
+ }
114
+
115
+ let newConfig = {...defaults, ...response}
116
+ if(undefined === newConfig.table.show) newConfig.table.show = !isDashboard
117
+ updateConfig(newConfig, data);
118
+ }
119
+
120
+ const updateConfig = (newConfig, dataOverride = undefined) => {
121
+
122
+ let data = dataOverride || stateData
123
+
124
+ // Deeper copy
125
+ Object.keys(defaults).forEach(key => {
126
+ if (newConfig[key] && 'object' === typeof newConfig[key] && !Array.isArray(newConfig[key])) {
127
+ newConfig[key] = { ...defaults[key], ...newConfig[key] }
128
+ }
129
+ });
130
+
131
+ // Loop through and set initial data with exclusions - this should persist through any following data transformations (ie. filters)
132
+ let newExcludedData
133
+
134
+ if (newConfig.exclusions && newConfig.exclusions.active) {
135
+
136
+ if (newConfig.xAxis.type === 'categorical' && newConfig.exclusions.keys?.length > 0) {
137
+ newExcludedData = data.filter(e => !newConfig.exclusions.keys.includes(e[newConfig.xAxis.dataKey]))
138
+ } else if (
139
+ newConfig.xAxis.type === 'date' &&
140
+ (newConfig.exclusions.dateStart || newConfig.exclusions.dateEnd) &&
141
+ newConfig.xAxis.dateParseFormat
142
+ ) {
143
+
144
+ // Filter dates
145
+ const timestamp = (e) => new Date(e).getTime();
146
+
147
+ let startDate = timestamp(newConfig.exclusions.dateStart)
148
+ let endDate = timestamp(newConfig.exclusions.dateEnd) + 86399999 //Increase by 24h in ms (86400000ms - 1ms) to include selected end date for .getTime() comparative
149
+
150
+ let startDateValid = undefined !== typeof startDate && false === isNaN(startDate)
151
+ let endDateValid = undefined !== typeof endDate && false === isNaN(endDate)
152
+
153
+ if (startDateValid && endDateValid) {
154
+ newExcludedData = data.filter(e =>
155
+ (timestamp(e[newConfig.xAxis.dataKey]) >= startDate) &&
156
+ (timestamp(e[newConfig.xAxis.dataKey]) <= endDate)
157
+ )
158
+ } else if (startDateValid) {
159
+ newExcludedData = data.filter(e => timestamp(e[newConfig.xAxis.dataKey]) >= startDate)
160
+ } else if (endDateValid) {
161
+ newExcludedData = data.filter(e => timestamp(e[newConfig.xAxis.dataKey]) <= endDate)
162
+ }
163
+
164
+ } else {
165
+ newExcludedData = dataOverride || stateData
166
+ }
167
+ } else {
168
+ newExcludedData = dataOverride || stateData
169
+ }
170
+
171
+ setExcludedData(newExcludedData)
172
+
173
+ // After data is grabbed, loop through and generate filter column values if there are any
174
+ let currentData;
175
+ if (newConfig.filters) {
176
+
177
+ newConfig.filters.forEach((filter, index) => {
178
+
179
+ let filterValues = [];
180
+
181
+ filterValues = generateValuesForFilter(filter.columnName, newExcludedData);
182
+
183
+ newConfig.filters[index].values = filterValues;
184
+ // Initial filter should be active
185
+ newConfig.filters[index].active = filterValues[0];
186
+
187
+ });
188
+
189
+ currentData = filterData(newConfig.filters, newExcludedData);
190
+ setFilteredData(currentData);
191
+ }
192
+
193
+ //Enforce default values that need to be calculated at runtime
194
+ newConfig.runtime = {};
195
+ newConfig.runtime.seriesLabels = {};
196
+ newConfig.runtime.seriesLabelsAll = [];
197
+ newConfig.runtime.originalXAxis = newConfig.xAxis;
198
+
199
+ if (newConfig.visualizationType === 'Pie') {
200
+ newConfig.runtime.seriesKeys = (dataOverride || data).map(d => d[newConfig.xAxis.dataKey]);
201
+ newConfig.runtime.seriesLabelsAll = newConfig.runtime.seriesKeys;
202
+ } else {
203
+ newConfig.runtime.seriesKeys = newConfig.series ? newConfig.series.map((series) => {
204
+ newConfig.runtime.seriesLabels[series.dataKey] = series.label || series.dataKey;
205
+ newConfig.runtime.seriesLabelsAll.push(series.label || series.dataKey);
206
+ return series.dataKey;
207
+ }) : [];
208
+ }
209
+
210
+ if (newConfig.visualizationType === 'Combo' && newConfig.series) {
211
+ newConfig.runtime.barSeriesKeys = [];
212
+ newConfig.runtime.lineSeriesKeys = [];
213
+ newConfig.series.forEach((series) => {
214
+ if(series.type === 'Bar'){
215
+ newConfig.runtime.barSeriesKeys.push(series.dataKey);
216
+ }
217
+ if(series.type === 'Line'){
218
+ newConfig.runtime.lineSeriesKeys.push(series.dataKey);
219
+ }
220
+ });
221
+ }
222
+
223
+ if ( (newConfig.visualizationType === 'Bar' && newConfig.orientation === 'horizontal') || newConfig.visualizationType === 'Paired Bar') {
224
+ newConfig.runtime.xAxis = newConfig.yAxis;
225
+ newConfig.runtime.yAxis = newConfig.xAxis;
226
+ newConfig.runtime.horizontal = true;
227
+ } else {
228
+ newConfig.runtime.xAxis = newConfig.xAxis;
229
+ newConfig.runtime.yAxis = newConfig.yAxis;
230
+ newConfig.runtime.horizontal = false;
231
+ }
232
+ newConfig.runtime.uniqueId = Date.now();
233
+ newConfig.runtime.editorErrorMessage = newConfig.visualizationType === 'Pie' && !newConfig.yAxis.dataKey ? 'Data Key property in Y Axis section must be set for pie charts.' : '';
234
+
235
+ // Check for duplicate x axis values in data
236
+ if(!currentData) currentData = newExcludedData;
237
+
238
+ let uniqueXValues = {};
239
+
240
+ if(newConfig.visualizationType !== 'Paired Bar') {
241
+ for(let i = 0; i < currentData.length; i++) {
242
+ if(uniqueXValues[currentData[i][newConfig.xAxis.dataKey]]){
243
+ newConfig.runtime.editorErrorMessage = 'Duplicate keys in data. Try adding a filter.';
244
+ } else {
245
+ uniqueXValues[currentData[i][newConfig.xAxis.dataKey]] = true;
246
+ }
247
+ }
248
+ }
249
+ setConfig(newConfig);
250
+ };
251
+
252
+ const filterData = (filters, data) => {
253
+ let filteredData = [];
254
+
255
+ data.forEach((row) => {
256
+ let add = true;
257
+ filters.forEach((filter) => {
258
+ if (row[filter.columnName] !== filter.active) {
259
+ add = false;
260
+ }
261
+ });
262
+ if(add) filteredData.push(row);
263
+ });
264
+ return filteredData;
265
+ }
266
+
267
+ // Gets filer values from dataset
268
+ const generateValuesForFilter = (columnName, data = this.state.data) => {
269
+ const values = [];
270
+
271
+ data.forEach( (row) => {
272
+ const value = row[columnName]
273
+ if(value && false === values.includes(value)) {
274
+ values.push(value)
275
+ }
276
+ });
277
+
278
+ return values;
279
+ }
280
+
281
+ // Sorts data series for horizontal bar charts
282
+ const sortData = (a, b) => {
283
+ let sortKey = config.visualizationType === 'Bar' && config.visualizationSubType === 'horizontal' ? config.xAxis.dataKey : config.yAxis.sortKey;
284
+ let aData = parseFloat(a[sortKey]);
285
+ let bData = parseFloat(b[sortKey]);
286
+
287
+ if(aData < bData){
288
+ return config.sortData === 'ascending' ? 1 : -1;
289
+ } else if (aData > bData){
290
+ return config.sortData === 'ascending' ? -1 : 1;
291
+ } else {
292
+ return 0;
293
+ }
294
+ }
295
+
296
+ // Observes changes to outermost container and changes viewport size in state
297
+ const resizeObserver:ResizeObserver = new ResizeObserver(entries => {
298
+ for (let entry of entries) {
299
+ let { width, height } = entry.contentRect
300
+ let newViewport = getViewport(width)
301
+ let svgMarginWidth = 32;
302
+ let editorWidth = 350;
303
+
304
+ setCurrentViewport(newViewport)
305
+
306
+ if(isEditor) {
307
+ width = width - editorWidth;
308
+ }
309
+
310
+ if(entry.target.dataset.lollipop === 'true') {
311
+ width = width - 2.5;
312
+ }
313
+
314
+ width = width - svgMarginWidth;
315
+
316
+ setDimensions([width, height])
317
+ }
318
+ })
319
+
320
+ const outerContainerRef = useCallback(node => {
321
+ if (node !== null) {
322
+ resizeObserver.observe(node);
323
+ }
324
+
325
+ setContainer(node)
326
+ },[]);
327
+
328
+ function isEmpty(obj) {
329
+ return Object.keys(obj).length === 0;
330
+ }
331
+
332
+ // Load data when component first mounts
333
+ useEffect(() => {
334
+ loadConfig();
335
+ }, []);
336
+
337
+ /**
338
+ * When cove has a config and container ref publish the cove_loaded event.
339
+ */
340
+ useEffect(() => {
341
+ if(container && !isEmpty(config) && !coveLoadedEventRan) {
342
+ publish('cove_loaded', { config: config })
343
+ setCoveLoadedEventRan(true)
344
+ }
345
+
346
+ }, [container, config]);
347
+
348
+
349
+ /**
350
+ * Handles filter change events outside of COVE
351
+ * Updates externalFilters state
352
+ * Another useEffect listens to externalFilterChanges and updates the config.
353
+ */
354
+ useEffect(() => {
355
+
356
+ const handleFilterData = (e:CustomEvent) => {
357
+ let tmp = [];
358
+ tmp.push(e.detail)
359
+ setExternalFilters(tmp)
360
+ }
361
+
362
+ subscribe('cove_filterData', (e:CustomEvent) => handleFilterData(e))
363
+
364
+ return () => {
365
+ unsubscribe('cove_filterData', handleFilterData);
366
+ }
367
+
368
+ }, [config]);
369
+
370
+
371
+ /**
372
+ * Handles changes to externalFilters
373
+ * For some reason e.detail is returning [order: "asc"] even though
374
+ * we're not passing that in. The code here checks for an active prop instead of an empty array.
375
+ */
376
+ useEffect(() => {
377
+
378
+ if(externalFilters[0]) {
379
+ const hasActiveProperty = externalFilters[0].hasOwnProperty('active')
380
+
381
+ if(!hasActiveProperty) {
382
+ let configCopy = {...config }
383
+ delete configCopy['filters']
384
+ setConfig(configCopy)
385
+ setFilteredData(filterData(externalFilters, excludedData));
386
+ }
387
+ }
388
+
389
+ if(externalFilters.length > 0 && externalFilters.length > 0 && externalFilters[0].hasOwnProperty('active')) {
390
+ let newConfigHere = {...config, filters: externalFilters }
391
+ setConfig(newConfigHere)
392
+ setFilteredData(filterData(externalFilters, excludedData));
393
+ }
394
+
395
+ }, [externalFilters]);
396
+
397
+
398
+ // Load data when configObj data changes
399
+ if(configObj){
400
+ useEffect(() => {
401
+ loadConfig();
402
+ }, [configObj.data]);
403
+ }
404
+
405
+ // Generates color palette to pass to child chart component
406
+ useEffect(() => {
407
+ if(stateData && config.xAxis && config.runtime.seriesKeys) {
408
+ let palette = config.customColors || colorPalettes[config.palette]
409
+ let numberOfKeys = config.runtime.seriesKeys.length
410
+
411
+ while(numberOfKeys > palette.length) {
412
+ palette = palette.concat(palette);
413
+ }
414
+
415
+ palette = palette.slice(0, numberOfKeys);
416
+
417
+ const newColorScale = () => scaleOrdinal({
418
+ domain: config.runtime.seriesLabelsAll,
419
+ range: palette,
420
+ });
421
+
422
+ setColorScale(newColorScale);
423
+ setLoading(false);
424
+ }
425
+
426
+ if(config && stateData && config.sortData){
427
+ stateData.sort(sortData);
428
+ }
429
+ }, [config, stateData])
430
+
431
+ // Called on legend click, highlights/unhighlights the data series with the given label
432
+ const highlight = (label) => {
433
+ const newSeriesHighlight = [];
434
+
435
+ // If we're highlighting all the series, reset them
436
+ if(seriesHighlight.length + 1 === config.runtime.seriesKeys.length) {
437
+ highlightReset()
438
+ return
439
+ }
440
+
441
+ seriesHighlight.forEach((value) => {
442
+ newSeriesHighlight.push(value);
443
+ });
444
+
445
+ let newHighlight = label.datum;
446
+ if(config.runtime.seriesLabels){
447
+ for(let i = 0; i < config.runtime.seriesKeys.length; i++) {
448
+ if(config.runtime.seriesLabels[config.runtime.seriesKeys[i]] === label.datum){
449
+ newHighlight = config.runtime.seriesKeys[i];
450
+ break;
451
+ }
452
+ }
453
+ }
454
+
455
+ if (newSeriesHighlight.indexOf(newHighlight) !== -1) {
456
+ newSeriesHighlight.splice(newSeriesHighlight.indexOf(newHighlight), 1);
457
+ } else {
458
+ newSeriesHighlight.push(newHighlight);
459
+ }
460
+
461
+ setSeriesHighlight(newSeriesHighlight);
462
+ };
463
+
464
+ // Called on reset button click, unhighlights all data series
465
+ const highlightReset = () => {
466
+ setSeriesHighlight([]);
467
+ }
468
+ const section = config.orientation ==='horizontal' ? 'yAxis' :'xAxis';
469
+
470
+ const parseDate = (dateString: string) => {
471
+ let date = timeParse(config.runtime[section].dateParseFormat)(dateString);
472
+ if(!date) {
473
+ config.runtime.editorErrorMessage = `Error parsing date "${dateString}". Try reviewing your data and date parse settings in the X Axis section.`;
474
+ return new Date();
475
+ } else {
476
+ return date;
477
+ }
478
+ };
479
+
480
+
481
+ const formatDate = (date: Date) => {
482
+ return timeFormat(config.runtime[section].dateDisplayFormat)(date);
483
+ };
484
+
485
+ // Format numeric data based on settings in config
486
+ const formatNumber = (num) => {
487
+ if(num === undefined || num ===null) return "";
488
+ // check if value contains comma and remove it. later will add comma below.
489
+ if(String(num).indexOf(',') !== -1) num = num.replaceAll(',', '');
490
+ // if num is NaN return num
491
+ if(isNaN(num)) return num ;
492
+
493
+ let original = num;
494
+ let prefix = config.dataFormat.prefix;
495
+
496
+ let stringFormattingOptions = {
497
+ useGrouping: config.dataFormat.commas ? true : false,
498
+ minimumFractionDigits: config.dataFormat.roundTo ? Number(config.dataFormat.roundTo) : 0,
499
+ maximumFractionDigits: config.dataFormat.roundTo ? Number(config.dataFormat.roundTo) : 0
500
+ };
501
+
502
+ num = numberFromString(num);
503
+
504
+ if(isNaN(num)) {
505
+ config.runtime.editorErrorMessage = `Unable to parse number from data ${original}. Try reviewing your data and selections in the Data Series section.`;
506
+ return original
507
+ }
508
+
509
+ if (!config.dataFormat) return num;
510
+ if (config.dataCutoff){
511
+ let cutoff = numberFromString(config.dataCutoff)
512
+
513
+ if(num < cutoff) {
514
+ prefix = '< ' + (prefix || '');
515
+ num = cutoff;
516
+ }
517
+ }
518
+ num = num.toLocaleString('en-US', stringFormattingOptions)
519
+
520
+ let result = ""
521
+
522
+ if(prefix) {
523
+ result += prefix
524
+ }
525
+
526
+ result += num
527
+
528
+ if(config.dataFormat.suffix) {
529
+ result += config.dataFormat.suffix
530
+ }
531
+ return String(result)
532
+ };
533
+
534
+ // Destructure items from config for more readable JSX
535
+ const { legend, title, description, visualizationType } = config;
536
+
537
+ // Select appropriate chart type
538
+ const chartComponents = {
539
+ 'Paired Bar' : <LinearChart />,
540
+ 'Bar' : <LinearChart />,
541
+ 'Line' : <LinearChart />,
542
+ 'Combo': <LinearChart />,
543
+ 'Pie' : <PieChart />,
544
+ }
545
+
546
+ // JSX for Legend
547
+ const Legend = () => {
548
+
549
+ let containerClasses = ['legend-container']
550
+ let innerClasses = ['legend-container__inner'];
551
+
552
+ if(config.legend.position === "left") {
553
+ containerClasses.push('left')
554
+ }
555
+
556
+ if(config.legend.reverseLabelOrder) {
557
+ innerClasses.push('d-flex')
558
+ innerClasses.push('flex-column-reverse')
559
+ }
560
+
561
+ return (
562
+ <aside id="legend" className={containerClasses.join(' ')} role="region" aria-label="legend" tabIndex={0}>
563
+ {legend.label && <h2>{legend.label}</h2>}
564
+ <LegendOrdinal
565
+ scale={colorScale}
566
+ itemDirection="row"
567
+ labelMargin="0 20px 0 0"
568
+ shapeMargin="0 10px 0"
569
+ >
570
+ {labels => (
571
+ <div className={innerClasses.join(' ')}>
572
+ {labels.map((label, i) => {
573
+ let className = 'legend-item'
574
+ let itemName:any = label.datum
575
+
576
+ // Filter excluded data keys from legend
577
+ if (config.exclusions.active && config.exclusions.keys?.includes(itemName)) {
578
+ return
579
+ }
580
+
581
+ if(config.runtime.seriesLabels){
582
+ let index = config.runtime.seriesLabelsAll.indexOf(itemName)
583
+ itemName = config.runtime.seriesKeys[index]
584
+ }
585
+
586
+ if( seriesHighlight.length > 0 && false === seriesHighlight.includes( itemName ) ) {
587
+ className += ' inactive'
588
+ }
589
+
590
+ return (
591
+ <LegendItem
592
+ className={className}
593
+ tabIndex={0}
594
+ key={`legend-quantile-${i}`}
595
+ onKeyPress={(e) => {
596
+ if (e.key === 'Enter') {
597
+ highlight(label);
598
+ }
599
+ }}
600
+ onClick={() => {
601
+ highlight(label);
602
+ }}
603
+ >
604
+ <LegendCircle fill={label.value} />
605
+ <LegendLabel align="left" margin="0 0 0 4px">
606
+ {label.text}
607
+ </LegendLabel>
608
+ </LegendItem>
609
+ )
610
+ })}
611
+ {seriesHighlight.length > 0 && <button className={`legend-reset ${config.theme}`} onClick={highlightReset}>Reset</button>}
612
+ </div>
613
+ )}
614
+ </LegendOrdinal>
615
+ </aside>
616
+ )
617
+ }
618
+
619
+ const Filters = () => {
620
+ const changeFilterActive = (index, value) => {
621
+ let newFilters = config.filters;
622
+
623
+ newFilters[index].active = value;
624
+
625
+ setConfig({...config, filters: newFilters});
626
+
627
+ setFilteredData(filterData(newFilters, excludedData));
628
+ };
629
+
630
+ const announceChange = (text) => {};
631
+
632
+ let filterList = '';
633
+ if (config.filters) {
634
+
635
+ filterList = config.filters.map((singleFilter, index) => {
636
+ const values = [];
637
+ const sortAsc = (a, b) => {
638
+ return a.toString().localeCompare(b.toString(), 'en', { numeric: true })
639
+ };
640
+
641
+ const sortDesc = (a, b) => {
642
+ return b.toString().localeCompare(a.toString(), 'en', { numeric: true })
643
+ };
644
+
645
+ if(!singleFilter.order || singleFilter.order === '' ){
646
+ singleFilter.order = 'asc'
647
+ }
648
+
649
+ if(singleFilter.order === 'desc') {
650
+ singleFilter.values = singleFilter.values.sort(sortDesc)
651
+ }
652
+
653
+ if(singleFilter.order === 'asc') {
654
+ singleFilter.values = singleFilter.values.sort(sortAsc)
655
+ }
656
+
657
+ singleFilter.values.forEach((filterOption, index) => {
658
+ values.push(
659
+ <option key={index} value={filterOption}>
660
+ {filterOption}
661
+ </option>
662
+ );
663
+ });
664
+
665
+ return (
666
+ <div className="single-filter" key={index}>
667
+ <label htmlFor={`filter-${index}`}>{singleFilter.label}</label>
668
+ <select
669
+ id={`filter-${index}`}
670
+ className="filter-select"
671
+ data-index="0"
672
+ value={singleFilter.active}
673
+ onChange={(val) => {
674
+ changeFilterActive(index, val.target.value);
675
+ announceChange(`Filter ${singleFilter.label} value has been changed to ${val.target.value}, please reference the data table to see updated values.`);
676
+ }}
677
+ >
678
+ {values}
679
+ </select>
680
+ </div>
681
+ );
682
+ });
683
+ }
684
+
685
+ return (<section className="filters-section">{filterList}</section>)
686
+ }
687
+
688
+ const missingRequiredSections = () => {
689
+ if (config.visualizationType === 'Pie') {
690
+ if (undefined === config?.yAxis.dataKey) {
691
+ return true;
692
+ }
693
+ } else {
694
+ if (undefined === config?.series || false === config?.series.length > 0) {
695
+ return true;
696
+ }
697
+ }
698
+
699
+ if (!config.xAxis.dataKey) {
700
+ return true;
701
+ }
702
+
703
+ return false;
704
+ };
705
+
706
+ let innerContainerClasses = ['cove-component__inner']
707
+ config.title && innerContainerClasses.push('component--has-title')
708
+ config.subtext && innerContainerClasses.push('component--has-subtext')
709
+ config.biteStyle && innerContainerClasses.push(`bite__style--${config.biteStyle}`)
710
+ config.general?.isCompactStyle && innerContainerClasses.push(`component--isCompactStyle`)
711
+
712
+ let contentClasses = ['cove-component__content'];
713
+ config.visualizationType === 'Spark Line' && contentClasses.push('sparkline')
714
+ !config.visual?.border && contentClasses.push('no-borders');
715
+ config.visual?.borderColorTheme && contentClasses.push('component--has-borderColorTheme');
716
+ config.visual?.accent && contentClasses.push('component--has-accent');
717
+ config.visual?.background && contentClasses.push('component--has-background');
718
+ config.visual?.hideBackgroundColor && contentClasses.push('component--hideBackgroundColor');
719
+
720
+
721
+
722
+ // Prevent render if loading
723
+ let body = (<Loading />)
724
+ let lineDatapointClass = ''
725
+ let barBorderClass = ''
726
+
727
+ let sparkLineStyles = {
728
+ width: '100%',
729
+ height: '100px',
730
+ }
731
+
732
+ if(false === loading) {
733
+ if (config.lineDatapointStyle === "hover") { lineDatapointClass = ' chart-line--hover' }
734
+ if (config.lineDatapointStyle === "always show") { lineDatapointClass = ' chart-line--always' }
735
+ if (config.barHasBorder === "false") { barBorderClass = ' chart-bar--no-border' }
736
+
737
+ body = (
738
+ <>
739
+ {isEditor && <EditorPanel />}
740
+ {!missingRequiredSections() && !config.newViz && <div className={`cdc-chart-inner-container`}>
741
+ <>
742
+ {/* Title */}
743
+ {title && <div role="heading" className={`chart-title ${config.theme} cove-component__header`} aria-level={2}>{parse(title)}</div>}
744
+ <a id='skip-chart-container' className='cdcdataviz-sr-only-focusable' href={handleChartTabbing}>
745
+ Skip Over Chart Container
746
+ </a>
747
+ {/* Filters */}
748
+ { (config.filters && !externalFilters ) && <Filters />}
749
+ {/* Visualization */}
750
+ <div className={`chart-container${config.legend.hide ? ' legend-hidden' : ''}${lineDatapointClass}${barBorderClass} ${contentClasses.join(' ')}`}>
751
+
752
+ {/* All charts except sparkline */}
753
+ {config.visualizationType !== "Spark Line" &&
754
+ chartComponents[visualizationType]
755
+ }
756
+
757
+ {/* Sparkline */}
758
+ {config.visualizationType === "Spark Line" && (
759
+ <>
760
+ { description && <div className="subtext">{parse(description)}</div>}
761
+ <div style={sparkLineStyles}>
762
+ <ParentSize>
763
+ {(parent) => (
764
+ <>
765
+ <SparkLine width={parent.width} height={parent.height} />
766
+ </>
767
+ )}
768
+ </ParentSize>
769
+ </div>
770
+ </>
771
+ )
772
+ }
773
+
774
+ {/* Legend */}
775
+ {(!config.legend.hide && config.visualizationType !== "Spark Line") && <Legend />}
776
+
777
+ </div>
778
+ </>
779
+ {/* Description */}
780
+ { (description && config.visualizationType !== "Spark Line") && <div className="subtext">{parse(description)}</div>}
781
+ {/* Data Table */}
782
+ { (config.xAxis.dataKey && config.table.show && config.visualizationType !== "Spark Line") && <DataTable />}
783
+ </div>
784
+ }
785
+ </>
786
+ )
787
+ }
788
+
789
+ const getXAxisData = (d: any) => config.runtime.xAxis.type === 'date' ? (parseDate(d[config.runtime.originalXAxis.dataKey])).getTime() : d[config.runtime.originalXAxis.dataKey];
790
+ const getYAxisData = (d: any, seriesKey: string) => d[seriesKey];
791
+
792
+ const contextValues = {
793
+ getXAxisData,
794
+ getYAxisData,
795
+ config,
796
+ rawData: stateData ?? {},
797
+ excludedData: excludedData,
798
+ transformedData: filteredData || excludedData,
799
+ unfilteredData: stateData,
800
+ seriesHighlight,
801
+ colorScale,
802
+ dimensions,
803
+ currentViewport,
804
+ parseDate,
805
+ formatDate,
806
+ formatNumber,
807
+ loading,
808
+ updateConfig,
809
+ colorPalettes,
810
+ isDashboard,
811
+ setParentConfig,
812
+ missingRequiredSections,
813
+ setEditing,
814
+ setFilteredData,
815
+ }
816
+
817
+ const classes = [
818
+ 'cdc-open-viz-module',
819
+ 'type-chart',
820
+ `${currentViewport}`,
821
+ `font-${config.fontSize}`,
822
+ `${config.theme}`
823
+ ]
824
+
825
+ config.visualizationType === "Spark Line" && classes.push(`type-sparkline`)
826
+ isEditor && classes.push('spacing-wrapper')
827
+ isEditor && classes.push('isEditor')
828
+
829
+ return (
830
+ <Context.Provider value={contextValues}>
831
+ <div className={`${classes.join(' ')}`} ref={outerContainerRef} data-lollipop={config.isLollipopChart}>
832
+ {body}
833
+ </div>
834
+ </Context.Provider>
835
+ );
836
+ }