@cdc/data-bite 1.1.1 → 1.1.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/LICENSE +201 -0
- package/dist/cdcdatabite.js +64 -0
- package/examples/example-config.json +26 -0
- package/examples/example-data.json +833 -0
- package/examples/private/WCMSRD-12345.json +1027 -0
- package/package.json +11 -13
- package/src/CdcDataBite.tsx +600 -0
- package/src/components/CircleCallout.js +26 -0
- package/src/components/EditorPanel.js +590 -0
- package/src/context.tsx +5 -0
- package/src/data/initial-state.js +39 -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 +19 -0
- package/src/index.tsx +16 -0
- package/src/scss/bite.scss +374 -0
- package/src/scss/editor-panel.scss +564 -0
- package/src/scss/main.scss +72 -0
- package/src/scss/variables.scss +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cdc/data-bite",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "React component for displaying a single piece of data in a card module",
|
|
5
5
|
"main": "dist/cdcdatabite",
|
|
6
6
|
"scripts": {
|
|
@@ -8,9 +8,6 @@
|
|
|
8
8
|
"build": "npx webpack --mode production --env packageName=CdcDataBite --env folderName=data-bite -c ../../webpack.config.js",
|
|
9
9
|
"prepublishOnly": "lerna run --scope @cdc/data-bite build"
|
|
10
10
|
},
|
|
11
|
-
"files": [
|
|
12
|
-
"./dist/"
|
|
13
|
-
],
|
|
14
11
|
"repository": {
|
|
15
12
|
"type": "git",
|
|
16
13
|
"url": "git+https://github.com/CDCgov/cdc-open-viz",
|
|
@@ -22,10 +19,12 @@
|
|
|
22
19
|
},
|
|
23
20
|
"license": "Apache-2.0",
|
|
24
21
|
"homepage": "https://github.com/CDCgov/cdc-open-viz#readme",
|
|
25
|
-
"
|
|
26
|
-
"@cdc/core": "^1.1.
|
|
27
|
-
"
|
|
28
|
-
"
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@cdc/core": "^1.1.3",
|
|
24
|
+
"chroma": "0.0.1",
|
|
25
|
+
"chroma-js": "^2.1.0",
|
|
26
|
+
"html-react-parser": "1.4.9",
|
|
27
|
+
"papaparse": "^5.3.0",
|
|
29
28
|
"react-accessible-accordion": "^3.3.4",
|
|
30
29
|
"react-beautiful-dnd": "^13.0.0",
|
|
31
30
|
"react-select": "^3.0.8",
|
|
@@ -33,13 +32,12 @@
|
|
|
33
32
|
"use-debounce": "^6.0.1",
|
|
34
33
|
"whatwg-fetch": "^3.6.2"
|
|
35
34
|
},
|
|
36
|
-
"dependencies": {
|
|
37
|
-
"chroma": "0.0.1",
|
|
38
|
-
"chroma-js": "^2.1.0"
|
|
39
|
-
},
|
|
40
35
|
"peerDependencies": {
|
|
41
36
|
"react": "^17.0.2",
|
|
42
37
|
"react-dom": ">=16"
|
|
43
38
|
},
|
|
44
|
-
"
|
|
39
|
+
"resolutions": {
|
|
40
|
+
"@types/react": "17.x"
|
|
41
|
+
},
|
|
42
|
+
"gitHead": "ff89a7aea74c533413c62ef8859cc011e6b3cbfa"
|
|
45
43
|
}
|
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import React, { useEffect, useState, useCallback,FC } from 'react';
|
|
2
|
+
import EditorPanel from './components/EditorPanel';
|
|
3
|
+
import defaults from './data/initial-state';
|
|
4
|
+
import Loading from '@cdc/core/components/Loading';
|
|
5
|
+
import getViewport from '@cdc/core/helpers/getViewport';
|
|
6
|
+
import ResizeObserver from 'resize-observer-polyfill';
|
|
7
|
+
import Papa from 'papaparse';
|
|
8
|
+
import parse from 'html-react-parser';
|
|
9
|
+
|
|
10
|
+
import Context from './context';
|
|
11
|
+
// @ts-ignore
|
|
12
|
+
import DataTransform from '@cdc/core/components/DataTransform';
|
|
13
|
+
import CircleCallout from './components/CircleCallout';
|
|
14
|
+
import './scss/main.scss';
|
|
15
|
+
import numberFromString from '@cdc/core/helpers/numberFromString';
|
|
16
|
+
import { Fragment } from 'react';
|
|
17
|
+
|
|
18
|
+
import { publish } from '@cdc/core/helpers/events'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
type DefaultsType = typeof defaults
|
|
22
|
+
interface Props{
|
|
23
|
+
configUrl?: string,
|
|
24
|
+
config?: any
|
|
25
|
+
isDashboard?: boolean
|
|
26
|
+
isEditor?: boolean
|
|
27
|
+
setConfig?:any
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CdcDataBite:FC<Props> = (props) => {
|
|
31
|
+
const { configUrl, config: configObj, isDashboard = false, isEditor = false, setConfig: setParentConfig } = props
|
|
32
|
+
|
|
33
|
+
const [config, setConfig] = useState<DefaultsType>({...defaults});
|
|
34
|
+
const [loading, setLoading] = useState<Boolean>(true);
|
|
35
|
+
|
|
36
|
+
const {
|
|
37
|
+
title,
|
|
38
|
+
dataColumn,
|
|
39
|
+
dataFunction,
|
|
40
|
+
imageData,
|
|
41
|
+
biteBody,
|
|
42
|
+
biteFontSize,
|
|
43
|
+
dataFormat,
|
|
44
|
+
biteStyle,
|
|
45
|
+
filters,
|
|
46
|
+
subtext,
|
|
47
|
+
general: { isCompactStyle }
|
|
48
|
+
} = config;
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
const transform = new DataTransform()
|
|
53
|
+
|
|
54
|
+
const [currentViewport, setCurrentViewport] = useState<String>('lg');
|
|
55
|
+
|
|
56
|
+
const [ coveLoadedHasRan, setCoveLoadedHasRan ] = useState(false)
|
|
57
|
+
|
|
58
|
+
const [ container, setContainer ] = useState()
|
|
59
|
+
|
|
60
|
+
//Observes changes to outermost container and changes viewport size in state
|
|
61
|
+
const resizeObserver = new ResizeObserver(entries => {
|
|
62
|
+
for (let entry of entries) {
|
|
63
|
+
let newViewport = getViewport(entry.contentRect.width * 2) // Data bite is usually presented as small, so we scale it up for responsive calculations
|
|
64
|
+
setCurrentViewport(newViewport)
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const fetchRemoteData = async (url) => {
|
|
69
|
+
try {
|
|
70
|
+
const urlObj = new URL(url);
|
|
71
|
+
const regex = /(?:\.([^.]+))?$/
|
|
72
|
+
|
|
73
|
+
let data = []
|
|
74
|
+
|
|
75
|
+
const ext = (regex.exec(urlObj.pathname)[1])
|
|
76
|
+
if ('csv' === ext) {
|
|
77
|
+
data = await fetch(url)
|
|
78
|
+
.then(response => response.text())
|
|
79
|
+
.then(responseText => {
|
|
80
|
+
const parsedCsv = Papa.parse(responseText, {
|
|
81
|
+
header: true,
|
|
82
|
+
dynamicTyping: true,
|
|
83
|
+
skipEmptyLines: true
|
|
84
|
+
})
|
|
85
|
+
return parsedCsv.data
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if ('json' === ext) {
|
|
90
|
+
data = await fetch(url)
|
|
91
|
+
.then(response => response.json())
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return data;
|
|
95
|
+
} catch {
|
|
96
|
+
// If we can't parse it, still attempt to fetch it
|
|
97
|
+
try {
|
|
98
|
+
let response = await (await fetch(configUrl)).json()
|
|
99
|
+
return response
|
|
100
|
+
} catch {
|
|
101
|
+
console.error(`Cannot parse URL: ${url}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const updateConfig = (newConfig) => {
|
|
107
|
+
// Deeper copy
|
|
108
|
+
Object.keys(defaults).forEach(key => {
|
|
109
|
+
if (newConfig[key] && 'object' === typeof newConfig[key] && !Array.isArray(newConfig[key])) {
|
|
110
|
+
newConfig[key] = { ...defaults[key], ...newConfig[key] }
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
//Enforce default values that need to be calculated at runtime
|
|
115
|
+
newConfig.runtime = {};
|
|
116
|
+
newConfig.runtime.uniqueId = Date.now();
|
|
117
|
+
|
|
118
|
+
//Check things that are needed and set error messages if needed
|
|
119
|
+
newConfig.runtime.editorErrorMessage = '';
|
|
120
|
+
setConfig(newConfig);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const loadConfig = async () => {
|
|
124
|
+
let response = configObj || await (await fetch(configUrl)).json();
|
|
125
|
+
|
|
126
|
+
const round = 1000 * 60 * 15;
|
|
127
|
+
const date = new Date();
|
|
128
|
+
let cacheBustingString = new Date(date.getTime() - (date.getTime() % round)).toISOString();
|
|
129
|
+
|
|
130
|
+
// If data is included through a URL, fetch that and store
|
|
131
|
+
let responseData = response.data ?? {}
|
|
132
|
+
|
|
133
|
+
if (response.dataUrl) {
|
|
134
|
+
response.dataUrl = `${response.dataUrl}?${cacheBustingString}`;
|
|
135
|
+
let newData = await fetchRemoteData(response.dataUrl)
|
|
136
|
+
|
|
137
|
+
if (newData && response.dataDescription) {
|
|
138
|
+
newData = transform.autoStandardize(newData);
|
|
139
|
+
newData = transform.developerStandardize(newData, response.dataDescription);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if(newData) {
|
|
143
|
+
responseData = newData;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
response.data = responseData;
|
|
148
|
+
|
|
149
|
+
updateConfig({ ...defaults, ...response });
|
|
150
|
+
|
|
151
|
+
setLoading(false);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const calculateDataBite = ():string|number => {
|
|
155
|
+
|
|
156
|
+
//If either the column or function aren't set, do not calculate
|
|
157
|
+
if (!dataColumn || !dataFunction) {
|
|
158
|
+
return '';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
const applyPrecision =(value:number|string):string => {
|
|
163
|
+
// first validation
|
|
164
|
+
if(value === undefined || value===null){
|
|
165
|
+
console.error('Enter correct value to "applyPrecision()" function ')
|
|
166
|
+
return ;
|
|
167
|
+
}
|
|
168
|
+
// second validation
|
|
169
|
+
if(Number.isNaN(value)){
|
|
170
|
+
console.error(' Argunment isNaN, "applyPrecision()" function ')
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
let result:number|string = value
|
|
174
|
+
let roundToPlace = Number(config.dataFormat.roundToPlace) // default equals to 0
|
|
175
|
+
// ROUND FIELD going -1,-2,-3 numbers
|
|
176
|
+
if(roundToPlace<0) {
|
|
177
|
+
console.error(' ROUND field is below "0", "applyPrecision()" function ')
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if(typeof roundToPlace ==='number' && roundToPlace > -1 ){
|
|
181
|
+
result = Number(result).toFixed(roundToPlace); // returns STRING
|
|
182
|
+
}
|
|
183
|
+
return String(result)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// filter null and 0 out from count data
|
|
187
|
+
const getColumnCount = (arr:(string|number)[]) => {
|
|
188
|
+
if(config.dataFormat.ignoreZeros) {
|
|
189
|
+
numericalData = numericalData.filter( item => item && item)
|
|
190
|
+
return numericalData.length
|
|
191
|
+
} else {
|
|
192
|
+
return numericalData.length
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const getColumnSum = (arr:(string|number)[]) => {
|
|
197
|
+
// first validation
|
|
198
|
+
if(arr===undefined || arr===null){
|
|
199
|
+
console.error('Enter valid value for getColumnSum function ')
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// second validation
|
|
203
|
+
if(arr.length === 0 || !Array.isArray(arr)){
|
|
204
|
+
console.error('Arguments are not valid getColumnSum function ')
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
let sum:number = 0
|
|
208
|
+
if(arr.length > 1){
|
|
209
|
+
/// first convert each element to number then add using reduce method to escape string concatination.
|
|
210
|
+
sum = arr.map(el=>Number(el)).reduce((sum:number, x:number) => sum + x);
|
|
211
|
+
}else {
|
|
212
|
+
sum = Number(arr[0])
|
|
213
|
+
}
|
|
214
|
+
return applyPrecision(sum);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const getColumnMean=(arr:(string|number)[]) => { // add default params to escape errors on runtime
|
|
218
|
+
// first validation
|
|
219
|
+
if(arr===undefined || arr===null ||!Array.isArray(arr)){
|
|
220
|
+
console.error('Enter valid parameter getColumnMean function')
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let mean:number = 0
|
|
225
|
+
if(arr.length > 1){
|
|
226
|
+
/// first convert each element to number then add using reduce method to escape string concatination.
|
|
227
|
+
mean = arr.map(el=>Number(el)).reduce((a, b) => a + b) / arr.length
|
|
228
|
+
}else {
|
|
229
|
+
mean = Number(arr[0])
|
|
230
|
+
}
|
|
231
|
+
return applyPrecision(mean);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const getMode = (arr:any[]=[]):string[] => { // add default params to escape errors on runtime
|
|
235
|
+
// this function accepts any array and returns array of strings
|
|
236
|
+
let freq = {}
|
|
237
|
+
let max = -Infinity
|
|
238
|
+
|
|
239
|
+
for(let i = 0; i < arr.length; i++) {
|
|
240
|
+
if (freq[arr[i]]) {
|
|
241
|
+
freq[arr[i]] += 1
|
|
242
|
+
} else {
|
|
243
|
+
freq[arr[i]] = 1
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (freq[arr[i]] > max) {
|
|
247
|
+
max = freq[arr[i]]
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let res = []
|
|
252
|
+
|
|
253
|
+
for(let key in freq) {
|
|
254
|
+
if(freq[key] === max) res.push(key)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return res
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const getMedian = arr => {
|
|
261
|
+
if(!arr.length) return ;
|
|
262
|
+
const mid = Math.floor(arr.length / 2),
|
|
263
|
+
nums = [...arr].sort((a, b) => a - b);
|
|
264
|
+
const value = arr.length % 2 !== 0 ? nums[mid] : (nums[mid - 1] + nums[mid]) / 2;
|
|
265
|
+
return applyPrecision(value);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const applyLocaleString = (value:string):string=>{
|
|
269
|
+
if(value===undefined || value===null) return ;
|
|
270
|
+
if(Number.isNaN(value)|| typeof value ==='number') {
|
|
271
|
+
value = String(value)
|
|
272
|
+
|
|
273
|
+
}
|
|
274
|
+
const language = 'en-US'
|
|
275
|
+
let formattedValue = parseFloat(value).toLocaleString(language, {
|
|
276
|
+
useGrouping: true,
|
|
277
|
+
maximumFractionDigits: 6
|
|
278
|
+
})
|
|
279
|
+
// Add back missing .0 in e.g. 12.0
|
|
280
|
+
const match = value.match(/\.\d*?(0*)$/)
|
|
281
|
+
|
|
282
|
+
if (match){
|
|
283
|
+
formattedValue += (/[1-9]/).test(match[0]) ? match[1] : match[0]
|
|
284
|
+
}
|
|
285
|
+
return formattedValue
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
let dataBite:string|number = '';
|
|
293
|
+
|
|
294
|
+
//Optionally filter the data based on the user's filter
|
|
295
|
+
let filteredData = config.data;
|
|
296
|
+
|
|
297
|
+
filters.map((filter) => {
|
|
298
|
+
if ( filter.columnName && filter.columnValue ) {
|
|
299
|
+
return filteredData = filteredData.filter(function (e) {
|
|
300
|
+
return e[filter.columnName] === filter.columnValue;
|
|
301
|
+
});
|
|
302
|
+
} else {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
let numericalData:any[] = []
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
// Get the column's data
|
|
311
|
+
if(filteredData.length){
|
|
312
|
+
filteredData.forEach(row => {
|
|
313
|
+
let value = numberFromString(row[dataColumn])
|
|
314
|
+
if(typeof value === 'number') numericalData.push(value)
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
switch (dataFunction) {
|
|
321
|
+
case DATA_FUNCTION_COUNT:
|
|
322
|
+
dataBite = getColumnCount(numericalData);
|
|
323
|
+
break;
|
|
324
|
+
case DATA_FUNCTION_SUM:
|
|
325
|
+
dataBite = getColumnSum(numericalData);
|
|
326
|
+
break;
|
|
327
|
+
case DATA_FUNCTION_MEAN:
|
|
328
|
+
dataBite = getColumnMean(numericalData);
|
|
329
|
+
break;
|
|
330
|
+
case DATA_FUNCTION_MEDIAN:
|
|
331
|
+
dataBite = getMedian(numericalData);
|
|
332
|
+
break;
|
|
333
|
+
case DATA_FUNCTION_MAX:
|
|
334
|
+
dataBite = Math.max(...numericalData);
|
|
335
|
+
break;
|
|
336
|
+
case DATA_FUNCTION_MIN:
|
|
337
|
+
dataBite =Math.min(...numericalData);
|
|
338
|
+
break;
|
|
339
|
+
case DATA_FUNCTION_MODE:
|
|
340
|
+
dataBite = getMode(numericalData).join('');
|
|
341
|
+
break;
|
|
342
|
+
case DATA_FUNCTION_RANGE:
|
|
343
|
+
let rangeMin :number|string = Math.min(...numericalData)
|
|
344
|
+
let rangeMax :number|string = Math.max(...numericalData)
|
|
345
|
+
rangeMin = applyPrecision(rangeMin)
|
|
346
|
+
rangeMax = applyPrecision(rangeMax)
|
|
347
|
+
if (config.dataFormat.commas) {
|
|
348
|
+
rangeMin = applyLocaleString(rangeMin)
|
|
349
|
+
rangeMax = applyLocaleString(rangeMax)
|
|
350
|
+
}
|
|
351
|
+
dataBite = config.dataFormat.prefix + rangeMin + config.dataFormat.suffix + ' - ' + config.dataFormat.prefix + rangeMax+config.dataFormat.suffix;
|
|
352
|
+
break;
|
|
353
|
+
default:
|
|
354
|
+
console.warn('Data bite function not recognized: ' + dataFunction);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// If not the range, then round and format here
|
|
358
|
+
if (dataFunction !== DATA_FUNCTION_RANGE) {
|
|
359
|
+
dataBite = applyPrecision(dataBite);
|
|
360
|
+
|
|
361
|
+
if (config.dataFormat.commas) {
|
|
362
|
+
dataBite = applyLocaleString(dataBite)
|
|
363
|
+
}
|
|
364
|
+
// Optional
|
|
365
|
+
// return config.dataFormat.prefix + dataBite + config.dataFormat.suffix;
|
|
366
|
+
|
|
367
|
+
return dataFormat.prefix + dataBite + dataFormat.suffix
|
|
368
|
+
} else {
|
|
369
|
+
//Rounding and formatting for ranges happens earlier.
|
|
370
|
+
|
|
371
|
+
return dataFormat.prefix + dataBite + dataFormat.suffix
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let innerContainerClasses = ['cove-component__inner']
|
|
376
|
+
config.title && innerContainerClasses.push('component--has-title')
|
|
377
|
+
config.subtext && innerContainerClasses.push('component--has-subtext')
|
|
378
|
+
config.biteStyle && innerContainerClasses.push(`bite__style--${config.biteStyle}`)
|
|
379
|
+
config.general?.isCompactStyle && innerContainerClasses.push(`component--isCompactStyle`)
|
|
380
|
+
|
|
381
|
+
let contentClasses = ['cove-component__content'];
|
|
382
|
+
!config.visual?.border && contentClasses.push('no-borders');
|
|
383
|
+
config.visual?.borderColorTheme && contentClasses.push('component--has-borderColorTheme');
|
|
384
|
+
config.visual?.accent && contentClasses.push('component--has-accent');
|
|
385
|
+
config.visual?.background && contentClasses.push('component--has-background');
|
|
386
|
+
config.visual?.hideBackgroundColor && contentClasses.push('component--hideBackgroundColor');
|
|
387
|
+
|
|
388
|
+
// ! these two will be retired.
|
|
389
|
+
config.shadow && innerContainerClasses.push('shadow')
|
|
390
|
+
config?.visual?.roundedBorders && innerContainerClasses.push('bite--has-rounded-borders')
|
|
391
|
+
|
|
392
|
+
// Load data when component first mounts
|
|
393
|
+
const outerContainerRef = useCallback(node => {
|
|
394
|
+
if (node !== null) {
|
|
395
|
+
resizeObserver.observe(node);
|
|
396
|
+
}
|
|
397
|
+
setContainer(node)
|
|
398
|
+
},[]);
|
|
399
|
+
|
|
400
|
+
// Initial load
|
|
401
|
+
useEffect(() => {
|
|
402
|
+
loadConfig()
|
|
403
|
+
publish('cove_loaded', { loadConfigHasRun: true })
|
|
404
|
+
}, [])
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
if (config && !coveLoadedHasRan && container) {
|
|
409
|
+
publish('cove_loaded', { config: config })
|
|
410
|
+
setCoveLoadedHasRan(true)
|
|
411
|
+
}
|
|
412
|
+
}, [config, container]);
|
|
413
|
+
|
|
414
|
+
if(configObj && config && configObj.data !== config.data){
|
|
415
|
+
loadConfig();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let body = (<Loading />)
|
|
419
|
+
|
|
420
|
+
const DataImage = useCallback(() => {
|
|
421
|
+
let operators = {
|
|
422
|
+
'<': (a, b) => { return a < b },
|
|
423
|
+
'>': (a, b) => { return a > b },
|
|
424
|
+
'<=': (a, b) => { return a <= b },
|
|
425
|
+
'>=': (a, b) => { return a >= b },
|
|
426
|
+
'≠': (a, b) => { return a !== b },
|
|
427
|
+
'=': (a, b) => { return a === b }
|
|
428
|
+
}
|
|
429
|
+
let imageSource = imageData.url
|
|
430
|
+
let imageAlt = imageData.alt
|
|
431
|
+
|
|
432
|
+
if ('dynamic' === imageData.display && imageData.options && imageData.options?.length > 0) {
|
|
433
|
+
let targetVal = Number(calculateDataBite())
|
|
434
|
+
let argumentActive = false
|
|
435
|
+
|
|
436
|
+
imageData.options.forEach((option, index) => {
|
|
437
|
+
let argumentArr = option.arguments
|
|
438
|
+
let { source, alt } = option
|
|
439
|
+
|
|
440
|
+
if (false === argumentActive && argumentArr.length > 0) {
|
|
441
|
+
if (argumentArr[0].operator.length > 0 && argumentArr[0].threshold.length > 0) {
|
|
442
|
+
if (operators[argumentArr[0].operator](targetVal, argumentArr[0].threshold)) {
|
|
443
|
+
if (undefined !== argumentArr[1]) {
|
|
444
|
+
if (argumentArr[1].operator?.length > 0 && argumentArr[1].threshold?.length > 0) {
|
|
445
|
+
if (operators[argumentArr[1].operator](targetVal, argumentArr[1].threshold)) {
|
|
446
|
+
imageSource = source
|
|
447
|
+
if (alt !== '' && alt !== undefined) { imageAlt = alt }
|
|
448
|
+
argumentActive = true
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
} else {
|
|
452
|
+
imageSource = source
|
|
453
|
+
if (alt !== '' && alt !== undefined) { imageAlt = alt }
|
|
454
|
+
argumentActive = true
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return (imageSource.length > 0 && 'graphic' !== biteStyle && 'none' !== imageData.display ? <img alt={imageAlt} src={imageSource} className="bite-image callout" /> : null)
|
|
463
|
+
}, [ imageData])
|
|
464
|
+
|
|
465
|
+
if(false === loading) {
|
|
466
|
+
let biteClasses = [];
|
|
467
|
+
|
|
468
|
+
let isTop = false
|
|
469
|
+
let isBottom = false
|
|
470
|
+
|
|
471
|
+
switch (config.bitePosition) {
|
|
472
|
+
case IMAGE_POSITION_LEFT:
|
|
473
|
+
biteClasses.push('bite-left');
|
|
474
|
+
isTop = true
|
|
475
|
+
break;
|
|
476
|
+
case IMAGE_POSITION_RIGHT:
|
|
477
|
+
biteClasses.push('bite-right');
|
|
478
|
+
isTop = true
|
|
479
|
+
break;
|
|
480
|
+
case IMAGE_POSITION_TOP:
|
|
481
|
+
biteClasses.push('bite-top');
|
|
482
|
+
isTop = true
|
|
483
|
+
break;
|
|
484
|
+
case IMAGE_POSITION_BOTTOM:
|
|
485
|
+
biteClasses.push('bite-bottom');
|
|
486
|
+
isBottom = true
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if(config.shadow) biteClasses.push('shadow')
|
|
491
|
+
|
|
492
|
+
const showBite = undefined !== dataColumn && undefined !== dataFunction;
|
|
493
|
+
|
|
494
|
+
body = (
|
|
495
|
+
<>
|
|
496
|
+
{isEditor && <EditorPanel />}
|
|
497
|
+
<div className={isEditor ? 'spacing-wrapper' : ''}>
|
|
498
|
+
<div className="cdc-data-bite-inner-container">
|
|
499
|
+
{title && <div className={`bite-header component__header ${config.theme}`}>{parse(title)}</div>}
|
|
500
|
+
<div className={`bite ${biteClasses.join(' ')} ${contentClasses.join(' ')}`}>
|
|
501
|
+
<div className={`bite-content-container ${innerContainerClasses.join(' ')}`}>
|
|
502
|
+
{showBite && 'graphic' === biteStyle && isTop && <CircleCallout theme={config.theme} text={calculateDataBite()} biteFontSize={biteFontSize} dataFormat={dataFormat} /> }
|
|
503
|
+
{isTop && <DataImage />}
|
|
504
|
+
<div className={`bite-content`}>
|
|
505
|
+
{showBite && 'title' === biteStyle && <div className="bite-value cove-component__header" style={{fontSize: biteFontSize + 'px'}}>{calculateDataBite()}</div>}
|
|
506
|
+
<Fragment>
|
|
507
|
+
<p className="bite-text">
|
|
508
|
+
{showBite && 'body' === biteStyle && <span className="bite-value data-bite-body" style={{fontSize: biteFontSize + 'px'}}>{calculateDataBite()}</span>}
|
|
509
|
+
{parse(biteBody)}
|
|
510
|
+
</p>
|
|
511
|
+
{subtext && !isCompactStyle && <p className="bite-subtext">{parse(subtext)}</p>}
|
|
512
|
+
</Fragment>
|
|
513
|
+
</div>
|
|
514
|
+
{isBottom && <DataImage />}
|
|
515
|
+
{showBite && 'graphic' === biteStyle && !isTop && <CircleCallout theme={config.theme} text={calculateDataBite()} biteFontSize={biteFontSize} dataFormat={dataFormat} /> }
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
</div>
|
|
519
|
+
</div>
|
|
520
|
+
</>
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
let classNames = [
|
|
525
|
+
'cdc-open-viz-module',
|
|
526
|
+
'type-data-bite',
|
|
527
|
+
currentViewport,
|
|
528
|
+
config.theme,
|
|
529
|
+
'font-' + config.fontSize
|
|
530
|
+
];
|
|
531
|
+
if (isEditor) {
|
|
532
|
+
classNames.push('is-editor');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return (
|
|
536
|
+
<Context.Provider value={{ config, updateConfig, loading, data: config.data, setParentConfig, isDashboard }}>
|
|
537
|
+
<div className={classNames.join(' ')} ref={outerContainerRef}>
|
|
538
|
+
{body}
|
|
539
|
+
</div>
|
|
540
|
+
</Context.Provider>
|
|
541
|
+
);
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
export default CdcDataBite;
|
|
545
|
+
|
|
546
|
+
/* Constant */
|
|
547
|
+
export const DATA_FUNCTION_MAX = 'Max';
|
|
548
|
+
export const DATA_FUNCTION_COUNT = 'Count';
|
|
549
|
+
export const DATA_FUNCTION_MEAN = 'Mean (Average)';
|
|
550
|
+
export const DATA_FUNCTION_MEDIAN = 'Median';
|
|
551
|
+
export const DATA_FUNCTION_MIN = 'Min';
|
|
552
|
+
export const DATA_FUNCTION_MODE = 'Mode';
|
|
553
|
+
export const DATA_FUNCTION_RANGE = 'Range';
|
|
554
|
+
export const DATA_FUNCTION_SUM = 'Sum';
|
|
555
|
+
export const DATA_FUNCTIONS = [
|
|
556
|
+
DATA_FUNCTION_COUNT,
|
|
557
|
+
DATA_FUNCTION_MAX,
|
|
558
|
+
DATA_FUNCTION_MEAN,
|
|
559
|
+
DATA_FUNCTION_MEDIAN,
|
|
560
|
+
DATA_FUNCTION_MIN,
|
|
561
|
+
DATA_FUNCTION_MODE,
|
|
562
|
+
DATA_FUNCTION_RANGE,
|
|
563
|
+
DATA_FUNCTION_SUM
|
|
564
|
+
];
|
|
565
|
+
|
|
566
|
+
export const BITE_LOCATION_TITLE = 'title';
|
|
567
|
+
export const BITE_LOCATION_BODY = 'body';
|
|
568
|
+
export const BITE_LOCATION_GRAPHIC = 'graphic';
|
|
569
|
+
export const BITE_LOCATIONS = {
|
|
570
|
+
'graphic': 'Graphic',
|
|
571
|
+
'title': 'Value above Message',
|
|
572
|
+
'body': 'Value before Message'
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
export const IMAGE_POSITION_LEFT = 'Left';
|
|
576
|
+
export const IMAGE_POSITION_RIGHT = 'Right';
|
|
577
|
+
export const IMAGE_POSITION_TOP = 'Top';
|
|
578
|
+
export const IMAGE_POSITION_BOTTOM = 'Bottom';
|
|
579
|
+
export const IMAGE_POSITIONS = [
|
|
580
|
+
IMAGE_POSITION_LEFT,
|
|
581
|
+
IMAGE_POSITION_RIGHT,
|
|
582
|
+
IMAGE_POSITION_TOP,
|
|
583
|
+
IMAGE_POSITION_BOTTOM,
|
|
584
|
+
];
|
|
585
|
+
|
|
586
|
+
export const DATA_OPERATOR_LESS = '<'
|
|
587
|
+
export const DATA_OPERATOR_GREATER = '>'
|
|
588
|
+
export const DATA_OPERATOR_LESSEQUAL = '<='
|
|
589
|
+
export const DATA_OPERATOR_GREATEREQUAL = '>='
|
|
590
|
+
export const DATA_OPERATOR_EQUAL = '='
|
|
591
|
+
export const DATA_OPERATOR_NOTEQUAL = '≠'
|
|
592
|
+
|
|
593
|
+
export const DATA_OPERATORS = [
|
|
594
|
+
DATA_OPERATOR_LESS,
|
|
595
|
+
DATA_OPERATOR_GREATER,
|
|
596
|
+
DATA_OPERATOR_LESSEQUAL,
|
|
597
|
+
DATA_OPERATOR_GREATEREQUAL,
|
|
598
|
+
DATA_OPERATOR_EQUAL,
|
|
599
|
+
DATA_OPERATOR_NOTEQUAL
|
|
600
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import themes from '@cdc/core/data/themes';
|
|
3
|
+
import chroma from 'chroma-js';
|
|
4
|
+
|
|
5
|
+
const CircleCallout = ({text, theme = 'theme-blue', dataFormat, biteFontSize}) => {
|
|
6
|
+
const styles = {
|
|
7
|
+
outerRing: {
|
|
8
|
+
fill: themes[theme].primary
|
|
9
|
+
},
|
|
10
|
+
innerRing: {
|
|
11
|
+
fill: chroma(themes[theme].primary).darken(0.5)
|
|
12
|
+
},
|
|
13
|
+
text: {
|
|
14
|
+
fill: '#FFF'
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" className="circle-callout callout">
|
|
20
|
+
<circle cx="50" cy="50" r="45" style={styles.innerRing} strokeWidth="10" stroke={styles.outerRing.fill} />
|
|
21
|
+
<text y="50%" x="50%" fontSize={biteFontSize} style={styles.text} textAnchor="middle" dominantBaseline="central">{text}</text>
|
|
22
|
+
</svg>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default CircleCallout
|