@cdc/core 4.23.4 → 4.23.5
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/assets/external-link.svg +1 -0
- package/components/DataTable.jsx +630 -0
- package/components/Filters.jsx +60 -7
- package/components/LegendCircle.jsx +2 -2
- package/data/colorPalettes.js +3 -3
- package/helpers/validateFipsCodeLength.js +1 -1
- package/package.json +2 -2
- package/styles/_data-table.scss +10 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M432 320h-32c-8.837 0-16 7.163-16 16v112H64V128h144c8.837 0 16-7.163 16-16V80c0-8.837-7.163-16-16-16H48C21.49 64 0 85.49 0 112v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V336c0-8.837-7.163-16-16-16zM488 0H360c-21.37 0-32.05 25.91-17 41l35.73 35.73L135 320.37a24 24 0 000 34L157.67 377a24 24 0 0034 0l243.61-243.68L471 169c15 15 41 4.5 41-17V24c0-13.255-10.745-24-24-24z" fill-rule="nonzero"/></svg>
|
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import React, { useEffect, useState, memo, useMemo } from 'react'
|
|
2
|
+
|
|
3
|
+
import Papa from 'papaparse'
|
|
4
|
+
import ExternalIcon from '../assets/external-link.svg' // TODO: Move to Icon component
|
|
5
|
+
import Icon from '@cdc/core/components/ui/Icon'
|
|
6
|
+
|
|
7
|
+
import ErrorBoundary from '@cdc/core/components/ErrorBoundary'
|
|
8
|
+
import LegendCircle from '@cdc/core/components/LegendCircle'
|
|
9
|
+
import CoveMediaControls from '@cdc/core/components/CoveMediaControls'
|
|
10
|
+
|
|
11
|
+
import Loading from '@cdc/core/components/Loading'
|
|
12
|
+
|
|
13
|
+
/* eslint-disable jsx-a11y/no-noninteractive-tabindex, jsx-a11y/no-static-element-interactions */
|
|
14
|
+
const DataTable = props => {
|
|
15
|
+
const { config, tableTitle, indexTitle, vizTitle, rawData, runtimeData, headerColor, expandDataTable, columns, displayDataAsText, formatNumber, applyLegendToRow, displayGeoName, navigationHandler, viewport, formatLegendLocation, tabbingId, parseDate, formatDate, isDebug } = props
|
|
16
|
+
if (isDebug) console.log('core/DataTable: props=', props)
|
|
17
|
+
if (isDebug) console.log('core/DataTable: runtimeData=', runtimeData)
|
|
18
|
+
if (isDebug) console.log('core/DataTable: rawData=', rawData)
|
|
19
|
+
if (isDebug) console.log('core/DataTable: config=', config)
|
|
20
|
+
|
|
21
|
+
const [expanded, setExpanded] = useState(expandDataTable)
|
|
22
|
+
|
|
23
|
+
const [sortBy, setSortBy] = useState({ column: config.type === 'map' ? 'geo' : 'date', asc: false })
|
|
24
|
+
|
|
25
|
+
const [accessibilityLabel, setAccessibilityLabel] = useState('')
|
|
26
|
+
|
|
27
|
+
const fileName = `${vizTitle || 'data-table'}.csv`
|
|
28
|
+
|
|
29
|
+
// Catch all sorting method used on load by default but also on user click
|
|
30
|
+
// Having a custom method means we can add in any business logic we want going forward
|
|
31
|
+
const customSort = (a, b) => {
|
|
32
|
+
const digitRegex = /\d+/
|
|
33
|
+
|
|
34
|
+
const hasNumber = value => digitRegex.test(value)
|
|
35
|
+
|
|
36
|
+
// force null and undefined to the bottom
|
|
37
|
+
a = a === null || a === undefined ? '' : a
|
|
38
|
+
b = b === null || b === undefined ? '' : b
|
|
39
|
+
|
|
40
|
+
// convert any strings that are actually numbers to proper data type
|
|
41
|
+
const aNum = Number(a)
|
|
42
|
+
|
|
43
|
+
if (!Number.isNaN(aNum)) {
|
|
44
|
+
a = aNum
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const bNum = Number(b)
|
|
48
|
+
|
|
49
|
+
if (!Number.isNaN(bNum)) {
|
|
50
|
+
b = bNum
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// remove iso code prefixes
|
|
54
|
+
if (typeof a === 'string') {
|
|
55
|
+
a = a.replace('us-', '')
|
|
56
|
+
a = displayGeoName(a)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof b === 'string') {
|
|
60
|
+
b = b.replace('us-', '')
|
|
61
|
+
b = displayGeoName(b)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// force any string values to lowercase
|
|
65
|
+
a = typeof a === 'string' ? a.toLowerCase() : a
|
|
66
|
+
b = typeof b === 'string' ? b.toLowerCase() : b
|
|
67
|
+
|
|
68
|
+
// If the string contains a number, remove the text from the value and only sort by the number. Only uses the first number it finds.
|
|
69
|
+
if (typeof a === 'string' && hasNumber(a) === true) {
|
|
70
|
+
a = a.match(digitRegex)[0]
|
|
71
|
+
|
|
72
|
+
a = Number(a)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof b === 'string' && hasNumber(b) === true) {
|
|
76
|
+
b = b.match(digitRegex)[0]
|
|
77
|
+
|
|
78
|
+
b = Number(b)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// When comparing a number to a string, always send string to bottom
|
|
82
|
+
if (typeof a === 'number' && typeof b === 'string') {
|
|
83
|
+
return 1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof b === 'number' && typeof a === 'string') {
|
|
87
|
+
return -1
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Return either 1 or -1 to indicate a sort priority
|
|
91
|
+
if (a > b) {
|
|
92
|
+
return 1
|
|
93
|
+
}
|
|
94
|
+
if (a < b) {
|
|
95
|
+
return -1
|
|
96
|
+
}
|
|
97
|
+
// returning 0, undefined or any falsey value will use subsequent sorts or
|
|
98
|
+
// the index as a tiebreaker
|
|
99
|
+
return 0
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Optionally wrap cell with anchor if config defines a navigation url
|
|
103
|
+
const getCellAnchor = (markup, row) => {
|
|
104
|
+
if (columns.navigate && row[columns.navigate.name]) {
|
|
105
|
+
markup = (
|
|
106
|
+
<span
|
|
107
|
+
onClick={() => navigationHandler(row[columns.navigate.name])}
|
|
108
|
+
className='table-link'
|
|
109
|
+
title='Click for more information (Opens in a new window)'
|
|
110
|
+
role='link'
|
|
111
|
+
tabIndex='0'
|
|
112
|
+
onKeyDown={e => {
|
|
113
|
+
if (e.keyCode === 13) {
|
|
114
|
+
navigationHandler(row[columns.navigate.name])
|
|
115
|
+
}
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
{markup}
|
|
119
|
+
<ExternalIcon className='inline-icon' />
|
|
120
|
+
</span>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return markup
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const rand = Math.random().toString(16).substr(2, 8)
|
|
128
|
+
const skipId = `btn__${rand}`
|
|
129
|
+
|
|
130
|
+
const mapLookup = {
|
|
131
|
+
'us-county': 'United States County Map',
|
|
132
|
+
'single-state': 'State Map',
|
|
133
|
+
us: 'United States Map',
|
|
134
|
+
world: 'World Map'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const DownloadButton = memo(() => {
|
|
138
|
+
if (rawData !== undefined) {
|
|
139
|
+
let csvData
|
|
140
|
+
if (config.type === 'chart' || config.general.type === 'bubble') {
|
|
141
|
+
// Just Unparse
|
|
142
|
+
csvData = Papa.unparse(rawData)
|
|
143
|
+
} else if ((config.general.geoType !== 'single-state' && config.general.geoType !== 'us-county') || config.general.type === 'us-geocode') {
|
|
144
|
+
// Unparse + Add column for full Geo name
|
|
145
|
+
csvData = Papa.unparse(rawData.map(row => ({ FullGeoName: displayGeoName(row[config.columns.geo.name]), ...row })))
|
|
146
|
+
} else {
|
|
147
|
+
// Unparse + Add column for full Geo name along with State
|
|
148
|
+
csvData = Papa.unparse(rawData.map(row => ({ FullGeoName: formatLegendLocation(row[config.columns.geo.name]), ...row })))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' })
|
|
152
|
+
|
|
153
|
+
const saveBlob = () => {
|
|
154
|
+
//@ts-ignore
|
|
155
|
+
if (typeof window.navigator.msSaveBlob === 'function') {
|
|
156
|
+
//@ts-ignore
|
|
157
|
+
navigator.msSaveBlob(blob, fileName)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<a download={fileName} type='button' onClick={saveBlob} href={URL.createObjectURL(blob)} aria-label='Download this data in a CSV file format.' className={`${headerColor} no-border`} id={`${skipId}`} data-html2canvas-ignore role='button'>
|
|
163
|
+
Download Data (CSV)
|
|
164
|
+
</a>
|
|
165
|
+
)
|
|
166
|
+
} else {
|
|
167
|
+
return <></>
|
|
168
|
+
}
|
|
169
|
+
}, [rawData])
|
|
170
|
+
|
|
171
|
+
// Change accessibility label depending on expanded status
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
const expandedLabel = 'Accessible data table.'
|
|
174
|
+
const collapsedLabel = 'Accessible data table. This table is currently collapsed visually but can still be read using a screen reader.'
|
|
175
|
+
|
|
176
|
+
if (expanded === true && accessibilityLabel !== expandedLabel) {
|
|
177
|
+
setAccessibilityLabel(expandedLabel)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (expanded === false && accessibilityLabel !== collapsedLabel) {
|
|
181
|
+
setAccessibilityLabel(collapsedLabel)
|
|
182
|
+
}
|
|
183
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
184
|
+
}, [expanded])
|
|
185
|
+
|
|
186
|
+
switch (config.visualizationType) {
|
|
187
|
+
case 'Box Plot':
|
|
188
|
+
if (!config.boxplot) return <Loading />
|
|
189
|
+
break
|
|
190
|
+
case 'Line' || 'Bar' || 'Combo' || 'Pie' || 'Deviation Bar' || 'Paired Bar':
|
|
191
|
+
if (!runtimeData) return <Loading />
|
|
192
|
+
break
|
|
193
|
+
default:
|
|
194
|
+
if (!runtimeData) return <Loading />
|
|
195
|
+
break
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const rows = Object.keys(runtimeData).sort((a, b) => {
|
|
199
|
+
let sortVal
|
|
200
|
+
if (config.columns.length > 0) {
|
|
201
|
+
sortVal = customSort(runtimeData[a][config.columns[sortBy.column].name], runtimeData[b][config.columns[sortBy.column].name])
|
|
202
|
+
}
|
|
203
|
+
if (!sortBy.asc) return sortVal
|
|
204
|
+
if (sortVal === 0) return 0
|
|
205
|
+
if (sortVal < 0) return 1
|
|
206
|
+
return -1
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
function genMapRows(rows) {
|
|
210
|
+
const allrows = rows.map(row => {
|
|
211
|
+
return (
|
|
212
|
+
<tr role='row'>
|
|
213
|
+
{Object.keys(columns)
|
|
214
|
+
.filter(column => columns[column].dataTable === true && columns[column].name)
|
|
215
|
+
.map(column => {
|
|
216
|
+
let cellValue
|
|
217
|
+
|
|
218
|
+
if (column === 'geo') {
|
|
219
|
+
const rowObj = runtimeData[row]
|
|
220
|
+
const legendColor = applyLegendToRow(rowObj)
|
|
221
|
+
|
|
222
|
+
var labelValue
|
|
223
|
+
if ((config.general.geoType !== 'single-state' && config.general.geoType !== 'us-county') || config.general.type === 'us-geocode') {
|
|
224
|
+
labelValue = displayGeoName(row)
|
|
225
|
+
} else {
|
|
226
|
+
labelValue = formatLegendLocation(row)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
labelValue = getCellAnchor(labelValue, rowObj)
|
|
230
|
+
cellValue = (
|
|
231
|
+
<>
|
|
232
|
+
<LegendCircle fill={legendColor[0]} />
|
|
233
|
+
{labelValue}
|
|
234
|
+
</>
|
|
235
|
+
)
|
|
236
|
+
} else {
|
|
237
|
+
// check for special classes
|
|
238
|
+
let specialValFound = ''
|
|
239
|
+
if (config.legend.specialClasses && config.legend.specialClasses.length && typeof config.legend.specialClasses[0] === 'object') {
|
|
240
|
+
for (let i = 0; i < config.legend.specialClasses.length; i++) {
|
|
241
|
+
if (config.legend.specialClasses[i].key === config.columns[column].name) {
|
|
242
|
+
if (String(runtimeData[row][config.legend.specialClasses[i].key]) === config.legend.specialClasses[i].value) {
|
|
243
|
+
specialValFound = config.legend.specialClasses[i].label
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
cellValue = specialValFound ? displayDataAsText(specialValFound, column) : displayDataAsText(runtimeData[row][config.columns[column].name], column)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<td tabIndex='0' role='gridcell' onClick={e => (config.general.type === 'bubble' && config.general.allowMapZoom && config.general.geoType === 'world' ? setFilteredCountryCode(row) : true)}>
|
|
253
|
+
{cellValue}
|
|
254
|
+
</td>
|
|
255
|
+
)
|
|
256
|
+
})}
|
|
257
|
+
</tr>
|
|
258
|
+
)
|
|
259
|
+
})
|
|
260
|
+
return allrows
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const dataSeriesColumns = () => {
|
|
264
|
+
let tmpSeriesColumns
|
|
265
|
+
if (config.visualizationType !== 'Pie') {
|
|
266
|
+
tmpSeriesColumns = [config.xAxis.dataKey] //, ...config.runtime.seriesLabelsAll
|
|
267
|
+
config.series.forEach(element => {
|
|
268
|
+
tmpSeriesColumns.push(element.dataKey)
|
|
269
|
+
})
|
|
270
|
+
} else {
|
|
271
|
+
tmpSeriesColumns = [config.xAxis.dataKey, config.yAxis.dataKey] //Object.keys(runtimeData[0])
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// then add the additional Columns
|
|
275
|
+
if (Object.keys(config.columns).length > 0) {
|
|
276
|
+
Object.keys(config.columns).forEach(function (key) {
|
|
277
|
+
var value = config.columns[key]
|
|
278
|
+
// add if not the index AND it is enabled to be added to data table
|
|
279
|
+
if (value.name !== config.xAxis.dataKey && value.dataTable === true) {
|
|
280
|
+
tmpSeriesColumns.push(value.name)
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return tmpSeriesColumns
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const getLabel = name => {
|
|
289
|
+
let custLabel = ''
|
|
290
|
+
if (Object.keys(config.columns).length > 0) {
|
|
291
|
+
Object.keys(config.columns).forEach(function (key) {
|
|
292
|
+
var tmpColumn = config.columns[key]
|
|
293
|
+
// add if not the index AND it is enabled to be added to data table
|
|
294
|
+
if (tmpColumn.name === name) {
|
|
295
|
+
custLabel = tmpColumn.label
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
return custLabel
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function genChartHeader(columns, data) {
|
|
303
|
+
return (
|
|
304
|
+
<tr>
|
|
305
|
+
{dataSeriesColumns().map(column => {
|
|
306
|
+
let custLabel = getLabel(column) ? getLabel(column) : column
|
|
307
|
+
let text = column === config.xAxis.dataKey ? config.table.indexLabel : custLabel
|
|
308
|
+
return (
|
|
309
|
+
<th
|
|
310
|
+
key={`col-header-${column}`}
|
|
311
|
+
tabIndex='0'
|
|
312
|
+
title={text}
|
|
313
|
+
role='columnheader'
|
|
314
|
+
scope='col'
|
|
315
|
+
onClick={() => {
|
|
316
|
+
setSortBy({ column, asc: sortBy.column === column ? !sortBy.asc : false })
|
|
317
|
+
}}
|
|
318
|
+
onKeyDown={e => {
|
|
319
|
+
if (e.keyCode === 13) {
|
|
320
|
+
setSortBy({ column, asc: sortBy.column === column ? !sortBy.asc : false })
|
|
321
|
+
}
|
|
322
|
+
}}
|
|
323
|
+
className={sortBy.column === column ? (sortBy.asc ? 'sort sort-asc' : 'sort sort-desc') : 'sort'}
|
|
324
|
+
{...(sortBy.column === column ? (sortBy.asc ? { 'aria-sort': 'ascending' } : { 'aria-sort': 'descending' }) : null)}
|
|
325
|
+
>
|
|
326
|
+
{text}
|
|
327
|
+
<button>
|
|
328
|
+
<span className='cdcdataviz-sr-only'>{`Sort by ${text} in ${sortBy.column === column ? (!sortBy.asc ? 'descending' : 'ascending') : 'descending'} `} order</span>
|
|
329
|
+
</button>
|
|
330
|
+
</th>
|
|
331
|
+
)
|
|
332
|
+
})}
|
|
333
|
+
</tr>
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function genChartRows(rows) {
|
|
338
|
+
const allrows = rows.map(row => {
|
|
339
|
+
return (
|
|
340
|
+
<tr role='row'>
|
|
341
|
+
{dataSeriesColumns()
|
|
342
|
+
//.filter(column => columns[column].dataTable === true && columns[column].name)
|
|
343
|
+
.map(column => {
|
|
344
|
+
let cellValue
|
|
345
|
+
if (column === config.xAxis.dataKey) {
|
|
346
|
+
const rowObj = runtimeData[row]
|
|
347
|
+
//const legendColor = applyLegendToRow(rowObj)
|
|
348
|
+
var labelValue = rowObj[column] // just raw X axis string
|
|
349
|
+
labelValue = getCellAnchor(labelValue, rowObj)
|
|
350
|
+
// no colors on row headers for charts bc it's Date not data
|
|
351
|
+
// Remove this - <LegendCircle fill={legendColor[row]} />
|
|
352
|
+
cellValue = <>{labelValue}</>
|
|
353
|
+
} else {
|
|
354
|
+
cellValue = displayDataAsText(runtimeData[row][column], column)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
//MAP SPECIFIC- change to CHART specific
|
|
358
|
+
// onClick = { e => (config.general.type === 'bubble' && config.general.allowMapZoom && config.general.geoType === 'world' ? setFilteredCountryCode(row) : true)}
|
|
359
|
+
return (
|
|
360
|
+
<td tabIndex='0' role='gridcell' id={`${runtimeData[config.runtime.originalXAxis.dataKey]}--${row}`}>
|
|
361
|
+
{cellValue}
|
|
362
|
+
</td>
|
|
363
|
+
)
|
|
364
|
+
})}
|
|
365
|
+
</tr>
|
|
366
|
+
)
|
|
367
|
+
})
|
|
368
|
+
return allrows
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const limitHeight = {
|
|
372
|
+
maxHeight: config.table.limitHeight && `${config.table.height}px`,
|
|
373
|
+
overflowY: 'scroll'
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const caption = useMemo(() => {
|
|
377
|
+
if (config.type === 'map') {
|
|
378
|
+
return config.table.caption ? config.table.caption : `Data table showing data for the ${mapLookup[config.general.geoType]} figure.`
|
|
379
|
+
} else {
|
|
380
|
+
return config.table.caption ? config.table.caption : `Data table showing data for the ${config.type} figure.`
|
|
381
|
+
}
|
|
382
|
+
}, [config.table.caption])
|
|
383
|
+
|
|
384
|
+
// prettier-ignore
|
|
385
|
+
const tableData = useMemo(() => (
|
|
386
|
+
config.visualizationType === 'Pie'
|
|
387
|
+
? [config.yAxis.dataKey]
|
|
388
|
+
: config.visualizationType === 'Box Plot'
|
|
389
|
+
? Object.entries(config.boxplot.tableData[0])
|
|
390
|
+
: config.runtime.seriesKeys),
|
|
391
|
+
[config.runtime.seriesKeys]) // eslint-disable-line
|
|
392
|
+
|
|
393
|
+
if (config.visualizationType !== 'Box Plot') {
|
|
394
|
+
function genMapHeader(columns) {
|
|
395
|
+
return (
|
|
396
|
+
<tr>
|
|
397
|
+
{Object.keys(columns)
|
|
398
|
+
.filter(column => columns[column].dataTable === true && columns[column].name)
|
|
399
|
+
.map(column => {
|
|
400
|
+
let text
|
|
401
|
+
if (column !== 'geo') {
|
|
402
|
+
text = columns[column].label ? columns[column].label : columns[column].name
|
|
403
|
+
} else {
|
|
404
|
+
text = config.type === 'map' ? indexTitle : config.xAxis.dataKey
|
|
405
|
+
}
|
|
406
|
+
if (config.type === 'map' && (text === undefined || text === '')) {
|
|
407
|
+
text = 'Location'
|
|
408
|
+
}
|
|
409
|
+
return (
|
|
410
|
+
<th
|
|
411
|
+
key={`col-header-${column}`}
|
|
412
|
+
tabIndex='0'
|
|
413
|
+
title={text}
|
|
414
|
+
role='columnheader'
|
|
415
|
+
scope='col'
|
|
416
|
+
onClick={() => {
|
|
417
|
+
setSortBy({ column, asc: sortBy.column === column ? !sortBy.asc : false })
|
|
418
|
+
}}
|
|
419
|
+
onKeyDown={e => {
|
|
420
|
+
if (e.keyCode === 13) {
|
|
421
|
+
setSortBy({ column, asc: sortBy.column === column ? !sortBy.asc : false })
|
|
422
|
+
}
|
|
423
|
+
}}
|
|
424
|
+
className={sortBy.column === column ? (sortBy.asc ? 'sort sort-asc' : 'sort sort-desc') : 'sort'}
|
|
425
|
+
{...(sortBy.column === column ? (sortBy.asc ? { 'aria-sort': 'ascending' } : { 'aria-sort': 'descending' }) : null)}
|
|
426
|
+
>
|
|
427
|
+
{text}
|
|
428
|
+
<button>
|
|
429
|
+
<span className='cdcdataviz-sr-only'>{`Sort by ${text} in ${sortBy.column === column ? (!sortBy.asc ? 'descending' : 'ascending') : 'descending'} `} order</span>
|
|
430
|
+
</button>
|
|
431
|
+
</th>
|
|
432
|
+
)
|
|
433
|
+
})}
|
|
434
|
+
</tr>
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return (
|
|
439
|
+
<ErrorBoundary component='DataTable'>
|
|
440
|
+
<CoveMediaControls.Section classes={['download-links']}>
|
|
441
|
+
<CoveMediaControls.Link config={config} />
|
|
442
|
+
{(config.table.download || config.general.showDownloadButton) && <DownloadButton />}
|
|
443
|
+
</CoveMediaControls.Section>
|
|
444
|
+
<section id={tabbingId.replace('#', '')} className={`data-table-container ${viewport}`} aria-label={accessibilityLabel}>
|
|
445
|
+
<a id='skip-nav' className='cdcdataviz-sr-only-focusable' href={`#${skipId}`}>
|
|
446
|
+
Skip Navigation or Skip to Content
|
|
447
|
+
</a>
|
|
448
|
+
<div
|
|
449
|
+
className={expanded ? 'data-table-heading' : 'collapsed data-table-heading'}
|
|
450
|
+
onClick={() => {
|
|
451
|
+
setExpanded(!expanded)
|
|
452
|
+
}}
|
|
453
|
+
tabIndex='0'
|
|
454
|
+
onKeyDown={e => {
|
|
455
|
+
if (e.keyCode === 13) {
|
|
456
|
+
setExpanded(!expanded)
|
|
457
|
+
}
|
|
458
|
+
}}
|
|
459
|
+
>
|
|
460
|
+
<Icon display={expanded ? 'minus' : 'plus'} base />
|
|
461
|
+
{tableTitle}
|
|
462
|
+
</div>
|
|
463
|
+
<div className='table-container' style={limitHeight}>
|
|
464
|
+
<table height={expanded ? null : 0} role='table' aria-live='assertive' className={expanded ? 'data-table' : 'data-table cdcdataviz-sr-only'} hidden={!expanded} aria-rowcount={config?.data?.length ? config.data.length : '-1'}>
|
|
465
|
+
<caption className='cdcdataviz-sr-only'>{caption}</caption>
|
|
466
|
+
<thead style={{ position: 'sticky', top: 0, zIndex: 999 }}>{config.type === 'map' ? genMapHeader(columns) : genChartHeader(columns, runtimeData)}</thead>
|
|
467
|
+
<tbody>{config.type === 'map' ? genMapRows(rows) : genChartRows(rows)}</tbody>
|
|
468
|
+
</table>
|
|
469
|
+
|
|
470
|
+
{/* REGION Data Table */}
|
|
471
|
+
{config.regions && config.regions.length > 0 && config.visualizationType !== 'Box Plot' ? (
|
|
472
|
+
<table className='region-table data-table'>
|
|
473
|
+
<caption className='visually-hidden'>Table of the highlighted regions in the visualization</caption>
|
|
474
|
+
<thead>
|
|
475
|
+
<tr>
|
|
476
|
+
<th>Region Name</th>
|
|
477
|
+
<th>Start Date</th>
|
|
478
|
+
<th>End Date</th>
|
|
479
|
+
</tr>
|
|
480
|
+
</thead>
|
|
481
|
+
<tbody>
|
|
482
|
+
{config.regions.map((region, index) => {
|
|
483
|
+
if (config.visualizationType === 'Box Plot') return false
|
|
484
|
+
if (!Object.keys(region).includes('from') || !Object.keys(region).includes('to')) return null
|
|
485
|
+
// region.from and region.to had formatDate(parseDate()) on it
|
|
486
|
+
// but they returned undefined - removed both for now (TT)
|
|
487
|
+
return (
|
|
488
|
+
<tr key={`row-${region.label}--${index}`}>
|
|
489
|
+
<td>{region.label}</td>
|
|
490
|
+
<td>{region.from}</td>
|
|
491
|
+
<td>{region.to}</td>
|
|
492
|
+
</tr>
|
|
493
|
+
)
|
|
494
|
+
})}
|
|
495
|
+
</tbody>
|
|
496
|
+
</table>
|
|
497
|
+
) : (
|
|
498
|
+
''
|
|
499
|
+
)}
|
|
500
|
+
</div>
|
|
501
|
+
</section>
|
|
502
|
+
</ErrorBoundary>
|
|
503
|
+
)
|
|
504
|
+
} else {
|
|
505
|
+
// Render Data Table for Box Plots
|
|
506
|
+
function genBoxplotHeader(categories) {
|
|
507
|
+
let columns = ['Measures', ...categories]
|
|
508
|
+
return (
|
|
509
|
+
<tr>
|
|
510
|
+
{columns.map(column => {
|
|
511
|
+
return (
|
|
512
|
+
<th key={`col-header-${column}`} tabIndex='0' title={column} role='columnheader' scope='col'>
|
|
513
|
+
{column}
|
|
514
|
+
</th>
|
|
515
|
+
)
|
|
516
|
+
})}
|
|
517
|
+
</tr>
|
|
518
|
+
)
|
|
519
|
+
}
|
|
520
|
+
const resolveName = key => {
|
|
521
|
+
let {
|
|
522
|
+
boxplot: { labels }
|
|
523
|
+
} = config
|
|
524
|
+
const columnLookup = {
|
|
525
|
+
columnMean: labels.mean,
|
|
526
|
+
columnMax: labels.maximum,
|
|
527
|
+
columnMin: labels.minimum,
|
|
528
|
+
columnIqr: labels.iqr,
|
|
529
|
+
columnCategory: 'Category',
|
|
530
|
+
columnMedian: labels.median,
|
|
531
|
+
columnFirstQuartile: labels.q1,
|
|
532
|
+
columnThirdQuartile: labels.q3,
|
|
533
|
+
columnOutliers: labels.outliers,
|
|
534
|
+
values: labels.values,
|
|
535
|
+
columnTotal: labels.total,
|
|
536
|
+
columnSd: 'Standard Deviation',
|
|
537
|
+
nonOutlierValues: 'Non Outliers',
|
|
538
|
+
columnLowerBounds: labels.lowerBounds,
|
|
539
|
+
columnUpperBounds: labels.upperBounds
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
let resolvedName = columnLookup[key]
|
|
543
|
+
|
|
544
|
+
return resolvedName
|
|
545
|
+
}
|
|
546
|
+
let resolveCell = (rowid, plot) => {
|
|
547
|
+
if (Number(rowid) === 0) return true
|
|
548
|
+
if (Number(rowid) === 1) return plot.columnMax
|
|
549
|
+
if (Number(rowid) === 2) return plot.columnThirdQuartile
|
|
550
|
+
if (Number(rowid) === 3) return plot.columnMedian
|
|
551
|
+
if (Number(rowid) === 4) return plot.columnFirstQuartile
|
|
552
|
+
if (Number(rowid) === 5) return plot.columnMin
|
|
553
|
+
if (Number(rowid) === 6) return plot.columnTotal
|
|
554
|
+
if (Number(rowid) === 7) return plot.columnSd
|
|
555
|
+
if (Number(rowid) === 8) return plot.columnMean
|
|
556
|
+
if (Number(rowid) === 9) return plot.columnOutliers.length > 0 ? plot.columnOutliers.toString() : '-'
|
|
557
|
+
if (Number(rowid) === 10) return plot.values.length > 0 ? plot.values.toString() : '-'
|
|
558
|
+
return <p>-</p>
|
|
559
|
+
}
|
|
560
|
+
function genBoxplotRows(rows) {
|
|
561
|
+
// get list of data keys for each row
|
|
562
|
+
let dataKeys = rows.map(row => {
|
|
563
|
+
return row[0]
|
|
564
|
+
})
|
|
565
|
+
let columns = ['Measures', ...config.boxplot.categories]
|
|
566
|
+
const allrows = dataKeys.map((rowkey, index) => {
|
|
567
|
+
if (index === 0) return '' // we did header column separately
|
|
568
|
+
let rowClass = `row-Box-Plot--${index}`
|
|
569
|
+
return (
|
|
570
|
+
<tr role='row' key={`tbody__tr-${index}`} className={rowClass}>
|
|
571
|
+
{columns.map((column, colnum) => {
|
|
572
|
+
let cellValue
|
|
573
|
+
if (column === 'Measures') {
|
|
574
|
+
let labelValue = index > 0 ? resolveName(rowkey) : ''
|
|
575
|
+
cellValue = <>{labelValue}</>
|
|
576
|
+
} else {
|
|
577
|
+
cellValue = resolveCell(index, config.boxplot.plots[colnum - 1])
|
|
578
|
+
}
|
|
579
|
+
return (
|
|
580
|
+
<td tabIndex='0' key={`tbody__tr__td-${index}`} className='boxplot-td' role='gridcell'>
|
|
581
|
+
{cellValue}
|
|
582
|
+
</td>
|
|
583
|
+
)
|
|
584
|
+
})}
|
|
585
|
+
</tr>
|
|
586
|
+
)
|
|
587
|
+
})
|
|
588
|
+
return allrows
|
|
589
|
+
}
|
|
590
|
+
return (
|
|
591
|
+
<ErrorBoundary component='DataTable'>
|
|
592
|
+
{/* cove media results in error so disabling for now (TT)
|
|
593
|
+
<CoveMediaControls.Section classes={['download-links']}>
|
|
594
|
+
<CoveMediaControls.Link config={config} />
|
|
595
|
+
{config.general.showDownloadButton && <DownloadButton />}
|
|
596
|
+
</CoveMediaControls.Section>
|
|
597
|
+
*/}
|
|
598
|
+
<section id={tabbingId.replace('#', '')} className={`data-table-container ${viewport}`} aria-label={accessibilityLabel}>
|
|
599
|
+
<a id='skip-nav' className='cdcdataviz-sr-only-focusable' href={`#${skipId}`}>
|
|
600
|
+
Skip Navigation or Skip to Content
|
|
601
|
+
</a>
|
|
602
|
+
<div
|
|
603
|
+
className={expanded ? 'data-table-heading' : 'collapsed data-table-heading'}
|
|
604
|
+
onClick={() => {
|
|
605
|
+
setExpanded(!expanded)
|
|
606
|
+
}}
|
|
607
|
+
tabIndex='0'
|
|
608
|
+
onKeyDown={e => {
|
|
609
|
+
if (e.keyCode === 13) {
|
|
610
|
+
setExpanded(!expanded)
|
|
611
|
+
}
|
|
612
|
+
}}
|
|
613
|
+
>
|
|
614
|
+
<Icon display={expanded ? 'minus' : 'plus'} base />
|
|
615
|
+
{tableTitle}
|
|
616
|
+
</div>
|
|
617
|
+
<div className='table-container' style={limitHeight}>
|
|
618
|
+
<table height={expanded ? null : 0} role='table' aria-live='assertive' className={expanded ? 'data-table' : 'data-table cdcdataviz-sr-only'} hidden={!expanded} aria-rowcount={'11'}>
|
|
619
|
+
<caption className='cdcdataviz-sr-only'>{caption}</caption>
|
|
620
|
+
<thead style={{ position: 'sticky', top: 0, zIndex: 999 }}>{genBoxplotHeader(config.boxplot.categories)}</thead>
|
|
621
|
+
<tbody>{genBoxplotRows(tableData)}</tbody>
|
|
622
|
+
</table>
|
|
623
|
+
</div>
|
|
624
|
+
</section>
|
|
625
|
+
</ErrorBoundary>
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export default DataTable
|
package/components/Filters.jsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react'
|
|
1
|
+
import React, { useState, useEffect, useRef } from 'react'
|
|
2
|
+
import { useId } from 'react'
|
|
2
3
|
|
|
3
4
|
// CDC
|
|
4
5
|
import Button from '@cdc/core/components/elements/Button'
|
|
@@ -72,7 +73,9 @@ export const useFilters = props => {
|
|
|
72
73
|
|
|
73
74
|
const changeFilterActive = (index, value) => {
|
|
74
75
|
let newFilters = visualizationConfig.type === 'map' ? [...filteredData] : [...visualizationConfig.filters]
|
|
76
|
+
|
|
75
77
|
newFilters[index].active = value
|
|
78
|
+
setConfig({ ...visualizationConfig })
|
|
76
79
|
|
|
77
80
|
// If this is a button filter type show the button.
|
|
78
81
|
if (visualizationConfig.filterBehavior === 'Apply Button') {
|
|
@@ -183,6 +186,8 @@ const Filters = props => {
|
|
|
183
186
|
const { config: visualizationConfig, filteredData, dimensions } = props
|
|
184
187
|
const { filters, type, general, theme, filterBehavior } = visualizationConfig
|
|
185
188
|
const [mobileFilterStyle, setMobileFilterStyle] = useState(false)
|
|
189
|
+
const [selectedFilter, setSelectedFilter] = useState('')
|
|
190
|
+
const id = useId()
|
|
186
191
|
|
|
187
192
|
// useFilters hook provides data and logic for handling various filter functions
|
|
188
193
|
// prettier-ignore
|
|
@@ -205,6 +210,13 @@ const Filters = props => {
|
|
|
205
210
|
}
|
|
206
211
|
}, [dimensions])
|
|
207
212
|
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
if (selectedFilter) {
|
|
215
|
+
let el = document.getElementById(selectedFilter.id)
|
|
216
|
+
if (el) el.focus()
|
|
217
|
+
}
|
|
218
|
+
}, [changeFilterActive, selectedFilter])
|
|
219
|
+
|
|
208
220
|
const Filters = props => props.children
|
|
209
221
|
|
|
210
222
|
const filterSectionClassList = ['filters-section', type === 'map' ? general.headerColor : theme]
|
|
@@ -241,10 +253,24 @@ const Filters = props => {
|
|
|
241
253
|
const { filter: singleFilter, index: outerIndex } = props
|
|
242
254
|
return (
|
|
243
255
|
<section className='single-filters__tab-bar'>
|
|
244
|
-
{singleFilter.values.map(filter => {
|
|
256
|
+
{singleFilter.values.map((filter, index) => {
|
|
245
257
|
const buttonClassList = ['button__tab-bar', singleFilter.active === filter ? 'button__tab-bar--active' : '']
|
|
246
258
|
return (
|
|
247
|
-
<button
|
|
259
|
+
<button
|
|
260
|
+
id={`${filter}-${outerIndex}-${index}-${id}`}
|
|
261
|
+
className={buttonClassList.join(' ')}
|
|
262
|
+
key={filter}
|
|
263
|
+
onClick={e => {
|
|
264
|
+
changeFilterActive(outerIndex, filter)
|
|
265
|
+
setSelectedFilter(e.target)
|
|
266
|
+
}}
|
|
267
|
+
onKeyDown={e => {
|
|
268
|
+
if (e.keyCode === 13) {
|
|
269
|
+
changeFilterActive(outerIndex, filter)
|
|
270
|
+
setSelectedFilter(e.target)
|
|
271
|
+
}
|
|
272
|
+
}}
|
|
273
|
+
>
|
|
248
274
|
{filter}
|
|
249
275
|
</button>
|
|
250
276
|
)
|
|
@@ -300,8 +326,22 @@ const Filters = props => {
|
|
|
300
326
|
const tabClassList = ['tab', active === filterOption && 'tab--active', theme && theme]
|
|
301
327
|
|
|
302
328
|
pillValues.push(
|
|
303
|
-
<div className='pill__wrapper'>
|
|
304
|
-
<button
|
|
329
|
+
<div className='pill__wrapper' key={`pill-${index}`}>
|
|
330
|
+
<button
|
|
331
|
+
id={`${filterOption}-${outerIndex}-${index}-${id}`}
|
|
332
|
+
className={pillClassList.join(' ')}
|
|
333
|
+
onKeyDown={e => {
|
|
334
|
+
if (e.keyCode === 13) {
|
|
335
|
+
changeFilterActive(outerIndex, filterOption)
|
|
336
|
+
setSelectedFilter(e.target)
|
|
337
|
+
}
|
|
338
|
+
}}
|
|
339
|
+
onClick={e => {
|
|
340
|
+
changeFilterActive(outerIndex, filterOption)
|
|
341
|
+
setSelectedFilter(e.target)
|
|
342
|
+
}}
|
|
343
|
+
name={label}
|
|
344
|
+
>
|
|
305
345
|
{filterOption}
|
|
306
346
|
</button>
|
|
307
347
|
</div>
|
|
@@ -309,12 +349,25 @@ const Filters = props => {
|
|
|
309
349
|
|
|
310
350
|
values.push(
|
|
311
351
|
<option key={index} value={filterOption}>
|
|
312
|
-
{filterOption}
|
|
352
|
+
{singleFilter.labels && singleFilter.labels[filterOption] ? singleFilter.labels[filterOption] : filterOption}
|
|
313
353
|
</option>
|
|
314
354
|
)
|
|
315
355
|
|
|
316
356
|
tabValues.push(
|
|
317
|
-
<button
|
|
357
|
+
<button
|
|
358
|
+
id={`${filterOption}-${outerIndex}-${index}-${id}`}
|
|
359
|
+
className={tabClassList.join(' ')}
|
|
360
|
+
onClick={e => {
|
|
361
|
+
changeFilterActive(outerIndex, filterOption)
|
|
362
|
+
setSelectedFilter(e.target)
|
|
363
|
+
}}
|
|
364
|
+
onKeyDown={e => {
|
|
365
|
+
if (e.keyCode === 13) {
|
|
366
|
+
changeFilterActive(outerIndex, filterOption)
|
|
367
|
+
setSelectedFilter(e.target)
|
|
368
|
+
}
|
|
369
|
+
}}
|
|
370
|
+
>
|
|
318
371
|
{filterOption}
|
|
319
372
|
</button>
|
|
320
373
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
|
|
3
|
-
export default function LegendCircle({ fill }) {
|
|
3
|
+
export default function LegendCircle({ fill, borderColor }) {
|
|
4
4
|
const styles = {
|
|
5
5
|
marginRight: '5px',
|
|
6
6
|
borderRadius: '300px',
|
|
@@ -8,7 +8,7 @@ export default function LegendCircle({ fill }) {
|
|
|
8
8
|
display: 'inline-block',
|
|
9
9
|
height: '1em',
|
|
10
10
|
width: '1em',
|
|
11
|
-
border: 'rgba(0,0,0,.3) 1px solid',
|
|
11
|
+
border: borderColor ? `${borderColor} 1px solid` : 'rgba(0,0,0,.3) 1px solid',
|
|
12
12
|
backgroundColor: fill
|
|
13
13
|
}
|
|
14
14
|
|
package/data/colorPalettes.js
CHANGED
|
@@ -51,16 +51,16 @@ export const colorPalettes3 = {
|
|
|
51
51
|
'monochrome-4': ['#C2C0FC', '#6a3d9a'],
|
|
52
52
|
'monochrome-5': ['#fedab8', '#bf5b17'],
|
|
53
53
|
'cool-1': ['#b2df8a', '#1f78b4'],
|
|
54
|
-
'cool-2': ['#a6cee3', '#
|
|
54
|
+
'cool-2': ['#a6cee3', '#72D66B'],
|
|
55
55
|
'cool-3': ['#C2C0FC', '#386cb0'],
|
|
56
|
-
'cool-4': ['#
|
|
56
|
+
'cool-4': ['#72D66B', '#6a3d9a'],
|
|
57
57
|
'cool-5': ['#a6cee3', '#6a3d9a'],
|
|
58
58
|
'warm-1': ['#e31a1c', '#fedab8'],
|
|
59
59
|
'complementary-1': ['#1f78b4', '#e6ab02'],
|
|
60
60
|
'complementary-2': ['#1f78b4', '#ff7f00'],
|
|
61
61
|
'complementary-3': ['#6a3d9a', '#ff7f00'],
|
|
62
62
|
'complementary-4': ['#6a3d9a', '#e6ab02'],
|
|
63
|
-
'complementary-5': ['#
|
|
63
|
+
'complementary-5': ['#df168c', '#1EB386']
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
export const colorPalettesChart = updatePaletteNames(colorPalettes2)
|
|
@@ -28,7 +28,7 @@ export default function validateFipsCodeLength(stateOrData) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
// Only includes data - get column name from somewhere else
|
|
31
|
-
if (Array.isArray(stateOrData)) {
|
|
31
|
+
if (Array.isArray(stateOrData) && stateOrData.length > 0) {
|
|
32
32
|
let columns = Object.keys(stateOrData[0])
|
|
33
33
|
|
|
34
34
|
let potentialColumnNames = ['fips', 'FIPS', 'fips codes', 'FIPS CODES', 'Fips Codes', 'fips Codes', 'Fips codes', 'FIPS Codes']
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cdc/core",
|
|
3
|
-
"version": "4.23.
|
|
3
|
+
"version": "4.23.5",
|
|
4
4
|
"description": "Core components, styles, hooks, and helpers, for the CDC Open Visualization project",
|
|
5
5
|
"moduleName": "CdcCore",
|
|
6
6
|
"main": "dist/cdccore",
|
|
@@ -30,5 +30,5 @@
|
|
|
30
30
|
"react": "^18.2.0",
|
|
31
31
|
"react-dom": "^18.2.0"
|
|
32
32
|
},
|
|
33
|
-
"gitHead": "
|
|
33
|
+
"gitHead": "34add3436994ca3cf13e51f313add4d70377f53e"
|
|
34
34
|
}
|
package/styles/_data-table.scss
CHANGED
|
@@ -39,6 +39,7 @@ table.data-table {
|
|
|
39
39
|
border-collapse: collapse;
|
|
40
40
|
overflow: auto;
|
|
41
41
|
appearance: none;
|
|
42
|
+
table-layout: fixed;
|
|
42
43
|
* {
|
|
43
44
|
box-sizing: border-box;
|
|
44
45
|
}
|
|
@@ -172,6 +173,15 @@ table.data-table {
|
|
|
172
173
|
margin-left: 5px;
|
|
173
174
|
}
|
|
174
175
|
}
|
|
176
|
+
|
|
177
|
+
.boxplot-td {
|
|
178
|
+
//display: inline-block;
|
|
179
|
+
//box-sizing: border-box;
|
|
180
|
+
table-layout: fixed;
|
|
181
|
+
width: 200;
|
|
182
|
+
//min-width: 150px;
|
|
183
|
+
//max-width: 400px;
|
|
184
|
+
}
|
|
175
185
|
}
|
|
176
186
|
|
|
177
187
|
.no-data {
|