@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.
- package/dist/cdcchart.js +85 -0
- package/examples/age-adjusted-rates.json +1218 -0
- package/examples/case-rate-example-config.json +36 -0
- package/examples/case-rate-example-data.json +33602 -0
- package/examples/covid-confidence-example-config.json +35 -0
- package/examples/covid-example-config.json +36 -0
- package/examples/covid-example-data-confidence.json +32 -0
- package/examples/covid-example-data.json +22 -0
- package/examples/cutoff-example-config.json +36 -0
- package/examples/cutoff-example-data.json +38 -0
- package/examples/date-exclusions-config.json +62 -0
- package/examples/date-exclusions-data.json +162 -0
- package/examples/horizontal-chart.json +35 -0
- package/examples/horizontal-stacked-bar-chart.json +36 -0
- package/examples/line-chart.json +76 -0
- package/examples/paired-bar-data.json +14 -0
- package/examples/paired-bar-example.json +48 -0
- package/examples/paired-bar-formatted.json +37 -0
- package/examples/planet-chart-horizontal-example-config.json +35 -0
- package/examples/planet-combo-example-config.json +31 -0
- package/examples/planet-example-config.json +35 -0
- package/examples/planet-example-data.json +56 -0
- package/examples/planet-pie-example-config.json +28 -0
- package/examples/private/newtest.csv +101 -0
- package/examples/private/test.json +10124 -0
- package/examples/temp-example-config.json +57 -0
- package/examples/temp-example-data.json +130 -0
- package/package.json +9 -8
- package/src/CdcChart.tsx +836 -0
- package/src/components/BarChart.tsx +571 -0
- package/src/components/BarStackVertical.js +0 -0
- package/src/components/DataTable.tsx +229 -0
- package/src/components/EditorPanel.js +1319 -0
- package/src/components/LineChart.tsx +76 -0
- package/src/components/LinearChart.tsx +459 -0
- package/src/components/PairedBarChart.tsx +144 -0
- package/src/components/PieChart.tsx +189 -0
- package/src/components/SparkLine.js +206 -0
- package/src/context.tsx +5 -0
- package/src/data/initial-state.js +61 -0
- package/src/hooks/useActiveElement.js +19 -0
- package/src/hooks/useColorPalette.ts +83 -0
- package/src/hooks/useReduceData.ts +43 -0
- package/src/images/active-checkmark.svg +1 -0
- package/src/images/asc.svg +1 -0
- package/src/images/desc.svg +1 -0
- package/src/images/inactive-checkmark.svg +1 -0
- package/src/images/warning.svg +1 -0
- package/src/index.html +68 -0
- package/src/index.tsx +21 -0
- package/src/scss/DataTable.scss +23 -0
- package/src/scss/LinearChart.scss +0 -0
- package/src/scss/editor-panel.scss +693 -0
- package/src/scss/main.scss +426 -0
- package/src/scss/mixins.scss +0 -0
- package/src/scss/variables.scss +1 -0
package/src/CdcChart.tsx
ADDED
|
@@ -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
|
+
}
|