stateChart = (stateAbbr, width, highlight = 'total') => {
const height = Math.floor(width / 1.618)
const margin = {left: 40, right: 40, top: 10, bottom: 20}
const stateData = annotateData(
statesData.filter(d => d.state === stateAbbr),
MOVING_AVERAGE_N
)
if (stateData.length === 0) return null
const xScale = d3.scaleTime()
.domain([DATA_START_DATE, d3.max(usData, getDate)])
.range([margin.left, width - margin.right])
const yScaleTotals = d3.scaleLinear()
.domain([0, d3.max(stateData, getTotalSMA)])
.range([height - margin.bottom, margin.top])
.nice()
const testLine = d3.line()
.defined(d => !isNaN(getTotalSMA(d)))
.x(d => xScale(getDate(d)))
.y(d => yScaleTotals(getTotalSMA(d)))
const yScalePercentage = d3.scaleLinear()
.domain([0, 1])
.range([height - margin.bottom, margin.top])
.nice()
const posPerTestLine = d3.line()
.defined(d => !isNaN(getPosPerTestSMA(d)))
.x(d => xScale(getDate(d)))
.y(d => yScalePercentage(getPosPerTestSMA(d)))
// Now generate the chart HTML and SVG
const wrapper = d3.select(html`<div class="state-chart"></div>`)
const svg = wrapper.append('svg')
.attr('width', width)
.attr('height', height)
// Add the name underneath the chart
wrapper.append('div')
.attr('class', 'chart-label')
.html(US_STATES[stateAbbr])
// Add the x-axis
svg.append('g')
.attr('transform', `translate(0, ${height - margin.bottom})`)
.call(
d3.axisBottom(xScale)
.ticks(width > 400 ? 5 : 3) // Fewer ticks for smaller screen
.tickFormat(d3.timeFormat(width > 400 ? '%b %d' : '%m/%d')) // Concise dates for smaller screen
.tickSize(5)
)
// Add y-axes
svg.append('g')
.attr('class', 'grid')
.attr('transform', `translate(${margin.left}, 0)`)
.call(
d3.axisLeft(highlight == 'total' ? yScaleTotals : yScalePercentage)
.ticks(5)
.tickFormat(highlight == 'total' ? d3.format('.2s') : d3.format('.0%'))
.tickSize(-width + margin.left + margin.right)
)
.call(g => g.select('.domain').remove())
// Add the test total data
svg.append('path')
.datum(stateData)
.attr('d', testLine)
.attr('fill', 'none')
.attr('stroke', TOTAL_COLOR)
.attr('stroke-width', highlight === 'total' ? 2 : 1)
.attr('stroke-dasharray', highlight === 'total' ? FOREGROUND_DASH : BACKGROUND_DASH)
// Add the pos per test data
svg.append('path')
.datum(stateData)
.attr('d', posPerTestLine)
.attr('fill', 'none')
.attr('stroke', POS_COLOR)
.attr('stroke-width', highlight === 'percentage' ? 2 : 1)
.attr('stroke-dasharray', highlight === 'percentage' ? FOREGROUND_DASH : BACKGROUND_DASH)
const finalPoint = stateData[stateData.length - 1]
const finalPointTotal = getTotalSMA(finalPoint)
svg.append('text')
.attr('x', width - margin.right + 5)
.attr('y', yScaleTotals(finalPointTotal))
.attr('font-family', 'sans-serif')
.attr('font-size', 12)
.attr('font-weight', 'bold')
.attr('fill', TOTAL_COLOR)
.attr('dominant-baseline', 'middle')
.attr('opacity', highlight === 'total' ? 1 : 0.25)
.text(d3.format('.2s')(finalPointTotal))
const finalPointPosPercentage = getPosPerTestSMA(finalPoint)
svg.append('text')
.attr('x', width - margin.right + 5)
.attr('y', yScalePercentage(finalPointPosPercentage))
.attr('font-family', 'sans-serif')
.attr('font-size', 12)
.attr('font-weight', 'bold')
.attr('fill', POS_COLOR)
.attr('dominant-baseline', 'middle')
.attr('opacity', highlight === 'percentage' ? 1 : 0.25)
.text(d3.format('.0%')(finalPointPosPercentage))
return wrapper.node()
}