/* Raw JS */ /** * Example usage: * StateRtTimeseriesChart.create( * document.getElementById("container"), * "Georgia" * ); */ window.StateRtTimeseriesChart = (() => { // For now, we assume the csv files are in the same directory as the page. const STATE_DATA_CSV_PATH = "/wcms/vizdata/cfa/RtEstimates/"; const DISEASE_CONFIG = { covid: { filename: "covid_timeseries_data.csv", name: "COVID-19", eventName: "rtEstimateCovidEvent", }, flu: { filename: "flu_timeseries_data.csv", name: "Influenza", eventName: "rtEstimateFluEvent", }, }; const VALID_DISEASES = Object.keys(DISEASE_CONFIG); const POINT_TYPE = "median"; function locationNameCompat(location) { // Previous versions of the data had "Virgin Islands", this was later changed if (location === "Virgin Islands") { return "US Virgin Islands"; } // The WCMS map / table uses "District Of Columbia" else if (location === "District Of Columbia") { return "District of Columbia"; } return location; } // What is the name for US overall in the dataset? const US_OVERALL_NAME = "United States"; /** * @param {string} text Raw csv text * @param {Function} processRow Re-format the row. * @param {Function?} filter Keep the processed row only if this function returns true. * @returns {Array} */ function parseCsv(text, processRow, filter) { const ROW_DELIMITER = "\n"; const COL_DELIMITER = ","; const rows = text.split(ROW_DELIMITER); const fields = rows .shift() .split(COL_DELIMITER) .map((f) => f.trim()); const data = []; for (const row of rows) { // Skip empty rows if (!row) { continue; } const cols = row.split(COL_DELIMITER); if (cols.length !== fields.length) { throw new Error(`Invalid row: ${row}`); } const d = cols.reduce((collector, value, i) => { collector[fields[i]] = value.trim(); return collector; }, {}); const processed = processRow(d); // If a filter function is defined, test the row if (filter && !filter(processed)) { continue; } // Reformat the row data.push(processed); } return data; } /** * Expects a CSV file: * Rows must be separated by \n * Columns must be by , * Values must not be quoted. * The file must contain a valid header row with column names. * * Expected columns: * * date: A date in YYYY-MM-DD form * state: The state name * median: The median as a float * lower: The lower CI as a float * upper: The upper CI as a float * interval_width: The CI width as a float * * Example: * * date,state,median,lower,upper,interval_width * 2023-11-25,New York,0.9995081252040742,0.8667338605320303,1.2001612616527282,0.95 */ async function getRtData(csvUrl) { let text = await (await fetch(csvUrl)).text(); return parseCsv( text, // Reformat each line (d) => ({ date: d3.timeParse("%Y-%m-%d")(d.date), date_string: d.date, state: d.state, [POINT_TYPE]: parseFloat(d[POINT_TYPE]), lower: parseFloat(d.lower), upper: parseFloat(d.upper), interval_width: parseFloat(d.interval_width), is_plotted_date: d.is_plotted_date.toLowerCase() === "true", }), // Filter out any empty rows or where is_plotted_date is false (d) => !isNaN(d[POINT_TYPE]) && d.is_plotted_date ); } function renderMissingStateMessage(container, state) { const el = document.createElement("div"); el.classList.add("missing-message"); el.textContent = `No data for ${state}. Please choose another location.`; container.appendChild(el); } /** * Given a list of numbers, a min and max value that when * coverted to log10 are equidistant from 1 and will contain * all numbers in the set * @param {number[]} numbers Array of positive numbers * @returns {[number, number]} */ function symmetricalLogExtent(numbers) { const logMax = numbers.reduce( (acc, current) => Math.max(acc, Math.abs(Math.log10(current))), 0 ); return [-logMax, logMax].map((l) => Math.pow(10, l)); } async function create(container, disease, state) { const diseaseConfig = DISEASE_CONFIG[disease]; const url = STATE_DATA_CSV_PATH + `${disease}/` + diseaseConfig.filename; const data = await getRtData(url); state = locationNameCompat(state); let stateData = []; let usData = []; for (const d of data) { if (d.state === US_OVERALL_NAME) { usData.push(d); } else if (d.state === state) { stateData.push(d); } } if (stateData.length === 0 && state !== US_OVERALL_NAME) { renderMissingStateMessage(container, state); } if (usData.length === 0) { console.warn( `No rows matching ${US_OVERALL_NAME} (data for the US overall) were found in ${url}.` ); } // Create a custom event for flu and covid const event = new CustomEvent(DISEASE_CONFIG[disease].eventName, { detail: { data: state ? stateData.findLast((x) => x.state === state) : usData.findLast((x) => true), disease: disease, }, }); // Dispatch the event on the iframe window // window.parent.postMessage( // { type: DISEASE_CONFIG[disease].eventName, data: event.detail }, // "*" // ); class TimeseriesChart { /** * @param {Object} options * @param {Object} options.container * @param {string} options.pointLabel, * @param {string} options.upperLabel, * @param {string} options.lowerLabel, * @param {number=} options.width * @param {number=} options.height * @param {HTMLElement=} [options.containerfTimeseriesChart] * @param {boolean} [options.autoHeight] Auto-resize chart to container height? * @param {number} [options.round] Round visible values */ constructor({ width = 960, height = 550, container: outerContainer, pointLabel, upperLabel, lowerLabel, autoHeight = false, round, }) { this.width = width; this.height = height; this.marginLeft = 60; this.marginRight = 20; this.marginTop = 40; this.marginBottom = 50; this.autoHeight = autoHeight; this.pointLabel = pointLabel; this.lowerLabel = lowerLabel; this.upperLabel = upperLabel; this.container = document.createElement("div"); this.container.classList.add("chart-container"); this.container.style.position = "relative"; this.shortDateFormat = d3.timeFormat("%b %e"); this.rtFormat = d3.format(`.${round}f`); // Create svg this.svg = d3.create("svg") .attr("font-family", "sans-serif") .attr("aria-label", "The estimated Rt and uncertainty interval for the U.S. and for each reported state.") .attr("role", "graphics-document"); // Append node this.container.appendChild(this.svg.node()); if (outerContainer) { outerContainer.appendChild(this.container); } this.setup(); } setup() { this.xScale = null; this.yScale = null; this.areaNode = this.svg.append("g"); this.lineNode = this.svg.append("g"); // Axis labels this.xAxisNode = this.svg.append("g"); this.yAxisNode = this.svg.append("g"); this.rtAnnotationNode = this.svg.append("g"); // Tooltip stuff this.tooltip = document.createElement("div"); this.tooltip.classList.add("tooltip"); this.tooltip.style.position = "absolute"; this.tooltip.style.width = "230px"; this.tooltip.style.display = "none"; this.container.appendChild(this.tooltip); this.bisect = d3.bisector((d) => d.date).center; this.oneLine = this.svg .append("line") .attr("stroke-width", 1) .style("stroke-dasharray", "2, 4") .attr("stroke", "rgba(0, 0, 0, 0.4)"); this.focusLine = this.svg .append("line") .style("display", "none") .attr("stroke-width", 1) .attr("stroke", "rgba(0, 0, 0, 0.2)"); this.dot = this.svg.append("g").style("display", "none"); this.onPointerEnter = this.onPointerEnter.bind(this); this.onPointerMove = this.onPointerMove.bind(this); this.onPointerLeave = this.onPointerLeave.bind(this); this.svg .on("pointerenter", this.onPointerEnter) .on("pointermove", this.onPointerMove) .on("pointerleave", this.onPointerLeave); // resize listener this.onWindowResize = this.onWindowResize.bind(this); let debouncer; window.addEventListener("resize", (e) => { clearTimeout(debouncer); debouncer = setTimeout(this.onWindowResize, 300); }); // Axis labels this.xAxisLabel = document.createElement("h4"); this.yAxisLabel = document.createElement("h4"); this.container.appendChild(this.xAxisLabel); this.container.appendChild(this.yAxisLabel); this.xAxisLabel.classList.add("axis-label-x"); this.yAxisLabel.classList.add("axis-label-y"); this.yAxisLabel.appendChild( renderEl("i", null, "R", renderEl("sub", null, "t")) ); this.yAxisLabel.append( ` (Time-Varying ${diseaseConfig.name} Reproductive Number)` ); this.xAxisLabel.textContent = "Infection Date"; } autoSize() { const containerRect = this.container.getBoundingClientRect(); if (!(containerRect.width > 0)) { return; } const heightRatio = this.height / this.width; this.width = containerRect.width; this.height = this.autoHeight && containerRect.height > 0 ? containerRect.height : this.width * heightRatio; } onWindowResize() { this.update(); } onPointerEnter() { if (!(this.tooltipData?.length > 0)) { return; } this.focusLine.style("display", null); this.dot.style("display", null); this.tooltip.style.display = "block"; } renderTooltipRow(d) { return renderEl( "tr", null, renderEl( "td", { style: `font-weight: bold; white-space: nowrap; color: ${d.color}`, }, d.state ), renderEl( "td", { className: "tooltip-table-right" }, this.rtFormat(d[this.pointLabel]), " ", renderEl( "span", { style: "font-size: 11px" }, renderEl("br", null), `(${this.rtFormat(d[this.lowerLabel])}-${this.rtFormat( d[this.upperLabel] )})` ) ) ); } renderTooltipContent(container, nearest) { const { data, date } = nearest; container.textContent = ""; container.appendChild( renderEl( "div", null, renderEl( "table", { className: "tooltip-table" }, renderEl( "thead", null, renderEl( "tr", null, renderEl("th", null, this.shortDateFormat(date)), renderEl( "th", { className: "tooltip-table-right" }, renderEl("i", null, "R", renderEl("sub", null, "t")), " estimate" ) ) ), renderEl( "tbody", null, ...data?.map((d) => this.renderTooltipRow(d)) ) ) ) ); } onPointerMove(event) { const mergedData = this.tooltipData; if (!(mergedData?.length > 0)) { return; } const x = d3.pointer(event)[0]; const i = this.bisect(mergedData, this.xScale.invert(x)); const nearest = mergedData[i]; const highestY = Math.max( ...nearest.data.map((d) => d[this.pointLabel]) ); this.focusLine.attr( "transform", `translate(${this.xScale(nearest.date)},0)` ); this.dot .selectAll("circle") .data(nearest.data) .join("circle") .attr("fill", (d) => d.color) .attr("r", 4) .attr( "transform", (d) => `translate(${this.xScale(d.date)},${this.yScale( d[this.pointLabel] )})` ); const leftPos = (this.xScale(nearest.date) / this.width) * 100; const topPos = ((this.yScale(highestY) + 3) / this.height) * 100; const tooltipSize = (this.tooltip.getBoundingClientRect().width / this.container.getBoundingClientRect().width) * 100; const shiftedX = Math.min( Math.max( (this.marginLeft / this.width) * 100, leftPos - tooltipSize / 2 ), 100 - tooltipSize - (this.marginRight / this.width) * 100 ); this.tooltip.style.left = `${shiftedX}%`; this.tooltip.style.top = `${topPos}%`; this.renderTooltipContent(this.tooltip, nearest); } onPointerLeave(event) { if (!(this.tooltipData?.length > 0)) { return; } this.dot.style("display", "none"); this.focusLine.style("display", "none"); this.tooltip.style.display = "none"; } update(options) { if (options) { this.state = options.state; this.stateData = options.stateData; this.usData = options.usData; this.enabled = { US: true, [options.state]: true }; } this.autoSize(); const isSmallScreen = this.width < 600; if (!this.state || !this.stateData) { return; } this.svg.attr("viewBox", [0, 0, this.width, this.height]); // Don't include dates for which we only have US data, // unless the state data is missing. const [minX, maxX] = d3.extent( (this.stateData?.length > 0 ? this.stateData : this.usData).map( (l) => l.date ) ); // Clip the US data this.usData = this.usData.filter( (d) => d.date >= minX && d.date <= maxX ); // Scales const xScale = d3.scaleUtc( [minX, maxX], [this.marginLeft, this.width - this.marginRight] ); this.xScale = xScale; const yScale = d3.scaleLog( symmetricalLogExtent( [...this.stateData, ...this.usData] .map((d) => [ d[this.pointLabel], d[this.lowerLabel], d[this.upperLabel], ]) .flat() ), [this.height - this.marginBottom, this.marginTop] ); this.yScale = yScale; const lineData = [ { label: "United States", data: this.usData, lineColor: "rgba(247, 143, 71, 1)", areaColor: "rgba(254, 227, 156, 0.2)", areaStroke: "rgba(254, 227, 156, 1)", enabled: this.enabled.US, }, ]; if (options.state !== US_OVERALL_NAME) { lineData.push({ label: this.state, data: this.stateData || [], lineColor: `rgba(29, 68, 153, 1)`, areaColor: `rgba(0, 20, 255, 0.06)`, areaStroke: `rgba(29, 68, 153, 0.2)`, enabled: this.enabled[this.state], }); } this.tooltipData = mergeDataByDate(lineData); if (this.legend) { this.container.removeChild(this.legend); } this.legend = document.createElement("div"); this.legend.classList.add("swatch-legend"); lineData.forEach((d) => { [ Swatch(d.lineColor, null, d.label, "line", d.enabled), Swatch( d.areaColor, "1px dashed " + d.areaStroke, `95% ${isSmallScreen ? "CI" : "Credible Interval"}`, "swatch", d.enabled ), ].forEach((el) => { this.legend?.appendChild(el); el.addEventListener("click", (e) => { this.enabled[d.label] = !this.enabled[d.label]; this.update(); }); }); }); this.legend.style.left = this.marginLeft + "px"; this.legend.style.top = this.marginTop + "px"; this.legend.style.transform = "translate(0, -100%)"; this.container.appendChild(this.legend); const line = d3 .line() .x((d) => xScale(d.date)) .y((d) => yScale(d[this.pointLabel])); const area = (data) => d3 .area() .x((d) => xScale(d.date)) .y0((d) => yScale(d[this.lowerLabel])) .y1((d) => yScale(d[this.upperLabel]))(data); // X Axis this.xAxisNode .attr("transform", `translate(0,${this.height - this.marginBottom})`) .call( d3 .axisBottom(xScale) .ticks(this.width / 80) .tickSizeOuter(0) ); // Y Axis this.yAxisNode .attr("transform", `translate(${this.marginLeft},0)`) .call( d3 .axisLeft(yScale) .ticks(this.height / 60) .tickFormat(d3.format(".2f")) ) .call((g) => g.select(".domain").remove()); this.lineNode .selectAll(".line") .data(lineData.filter((l) => l.enabled)) .join("path") .attr("class", "line") .attr("fill", "none") .attr("stroke", (d) => d.lineColor) .attr("stroke-width", 3) .attr("d", (d) => line(d.data)); this.areaNode .selectAll(".area") .data(lineData.filter((l) => l.enabled)) .join("path") .attr("class", "area") // .attr("stroke", (d) => d.areaStroke) // .style("stroke-dasharray", "3, 3") // .attr("stroke-width", 1) // .style("stroke-dasharray", "3, 3") .attr("fill", (d) => d.areaColor) .attr("d", (d) => area(d.data)); this.focusLine .attr("y1", this.height - this.marginBottom) .attr("y2", this.marginTop); this.oneLine .attr("x1", this.marginLeft) .attr("x2", this.width - this.marginRight) .attr("y1", yScale(1)) .attr("y2", yScale(1)); this.rtAnnotationNode .selectAll("text") .data( [ { label: "Growing \u2192", anchor: "start" }, { label: "\u2190 Declining", anchor: "end" }, ] .map((d) => [{ ...d, color: "white", stroke: 2 }, d]) .flat() ) .join("text") .attr("fill", (d) => d.color) .attr("stroke", (d) => d.color) .attr("stroke-width", (d) => (d.color ? 2 : 0)) .attr("text-anchor", (d) => d.anchor) .attr("font-size", isSmallScreen ? 10 : 12) .text((d) => d.label) .attr( "transform", (d) => `translate(${this.width - this.marginLeft + 20}, ${ yScale(1) + (d.anchor === "start" ? -1 : 1) * 8 }) rotate(-90)` ); } node() { return this.container; } } function mergeDataByDate(dataSets) { const byDate = new Map(); dataSets .filter((d) => d.enabled) .forEach((dataSet) => dataSet.data.forEach((d) => { if (!byDate.get(d.date_string)) { byDate.set(d.date_string, { date: d.date, data: [] }); } byDate .get(d.date_string) .data.push({ ...d, color: dataSet.lineColor }); }) ); return Array.from(byDate.values()); } // Swatches for legend const Swatch = (color, border, label, type, enabled) => renderEl( "div", { className: "swatch-item", style: enabled ? "" : `opacity: 0.5` }, renderEl("div", { className: type === "line" ? "line" : "swatch", style: `background-color: ${color}; border: ${ border ? border : "none" };`, }), label ); // From utils.js function renderEl(type, props, ...contents) { const el = document.createElement(type); if (props) { for (const key in props) { if (key === "className") { el.className = props[key]; } else { el.setAttribute(key, props[key]); } } } if (contents.length) { contents.forEach((item) => { if (typeof item === "undefined" || item === null) { return; } if (typeof item !== "object") { item = document.createTextNode(item); } el.appendChild(item); }); } return el; } const chart = new TimeseriesChart({ container, pointLabel: POINT_TYPE, upperLabel: "upper", lowerLabel: "lower", round: 2, autoHeight: true, }); chart.update({ state, usData, stateData }); } return { VALID_DISEASES, US_OVERALL_NAME, getRtData, create, }; })(); /* End Raw JS */ 国产精品久久久久久一级毛片