From 4a005fae4798adae3c819c6a4089eb450d9a60e9 Mon Sep 17 00:00:00 2001 From: Sam Bessey Date: Mon, 25 Sep 2023 12:31:44 -0400 Subject: [PATCH 01/14] refactor: allow swapping between rate and pct in gapbyrace --- src/components/dashboard/GapByRace.vue | 29 +++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/components/dashboard/GapByRace.vue b/src/components/dashboard/GapByRace.vue index bcee77e..528d51c 100644 --- a/src/components/dashboard/GapByRace.vue +++ b/src/components/dashboard/GapByRace.vue @@ -17,7 +17,7 @@ :class="focusStat.value === 'total' ? '' : 'is-invisible'" >
- import { computed } from "vue"; +import { format } from "d3-format"; -import GapChart from "@/components/dashboard/GapChart.vue"; +import GapChartPct from "@/components/dashboard/GapChartPct.vue"; import KeyPerformanceIndicator from "@/components/dashboard/KeyPerformanceIndicator.vue"; import { formatPct, sortByProperty } from "../../utils/utils"; import { compile } from "handlebars"; @@ -161,6 +160,7 @@ const props = defineProps<{ fieldNames: Array<{ field: string; name: string }>; focusStat: FocusStat; phrases: Phrases; + displayAsRate: Boolean; }>(); const expected = computed( @@ -191,6 +191,10 @@ const activeStats = computed(() => { population > 0 ? Math.min(0.99, row[f.observedField] / row[f.populationField]) : NaN, + rate: + population > 0 + ? row[f.observedField] / row[f.populationField] * 100000 + : NaN, gap: Math.max(0, row[f.expectedField] - row[f.observedField]), population, }; @@ -219,6 +223,21 @@ const activeFocusStats = computed(() => { ); }); +const kpiValue = computed(() => { + if (activeFocusStats.value?.population > 0) { + if (props.displayAsRate) { + return activeFocusStats.value?.rate.toLocaleString("en-US", { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }) + } else { + return formatPct(activeFocusStats.value?.pct) + } + } else { + return '?' + } +}); + const gapPhrase = compile(props.phrases.gap); const allResidents = compile(props.phrases.allResidents); const noGap = compile(props.phrases.noGap); From a0a38f503c7dc3565798286968df417a3f7f0c4f Mon Sep 17 00:00:00 2001 From: Sam Bessey Date: Mon, 25 Sep 2023 17:54:03 -0400 Subject: [PATCH 02/14] feat: create testing coldspot page; start to create gap chart for rates --- scripts/upload-data.js | 52 +++ src/components/base/SideBar.vue | 5 + src/components/dashboard/GapByRace.vue | 9 +- .../{GapChart.vue => GapChartPct.vue} | 2 +- src/components/dashboard/GapChartRate.vue | 225 ++++++++++ src/router.ts | 5 + src/views/datasets/booster-gap/dashboard.vue | 1 + src/views/datasets/vaccine-gap/dashboard.vue | 1 + .../historical/testing-gap/dashboard.vue | 391 ++++++++++++++++++ src/views/historical/testing-gap/index.vue | 77 ++++ 10 files changed, 765 insertions(+), 3 deletions(-) rename src/components/dashboard/{GapChart.vue => GapChartPct.vue} (98%) create mode 100644 src/components/dashboard/GapChartRate.vue create mode 100644 src/views/historical/testing-gap/dashboard.vue create mode 100644 src/views/historical/testing-gap/index.vue diff --git a/scripts/upload-data.js b/scripts/upload-data.js index 0d89150..4bd1136 100644 --- a/scripts/upload-data.js +++ b/scripts/upload-data.js @@ -359,6 +359,58 @@ const main = async () => { }); } + if (id === "testing_coldspots") { + files.push({ + filePath: statebarriersfile, + extension: "json", + field: "state_barriers", + isArray: false, + schema: [ + { + name: "pct_w_no_vehicle", + type: "number", + }, + { + name: "pct_w_no_english", + type: "number", + }, + { + name: "pct_w_no_insurance", + type: "number", + }, + { + name: "pct_w_no_internet", + type: "number", + }, + ], + }); + + files.push({ + filePath: barriersfile, + extension: "json", + field: "barriers", + isArray: true, + schema: [ + { + name: "pct_w_no_vehicle", + type: "number", + }, + { + name: "pct_w_no_english", + type: "number", + }, + { + name: "pct_w_no_insurance", + type: "number", + }, + { + name: "pct_w_no_internet", + type: "number", + }, + ], + }); + } + files.forEach(({ filePath, extension }) => { if (!fs.existsSync(filePath)) { warnAndExit(`ERROR! File does not exist: ${filePath}`); diff --git a/src/components/base/SideBar.vue b/src/components/base/SideBar.vue index 84f37ae..34e2910 100644 --- a/src/components/base/SideBar.vue +++ b/src/components/base/SideBar.vue @@ -204,6 +204,11 @@ const HISTORICAL = [ route: "hospitalization-hotspot", available: true, }, + { + name: "Testing Gaps", + route: "testing-gap", + available: true + } ]; const SPOTLIGHTS = [ diff --git a/src/components/dashboard/GapByRace.vue b/src/components/dashboard/GapByRace.vue index 528d51c..24e3896 100644 --- a/src/components/dashboard/GapByRace.vue +++ b/src/components/dashboard/GapByRace.vue @@ -17,7 +17,12 @@ :class="focusStat.value === 'total' ? '' : 'is-invisible'" >
- + import { computed } from "vue"; -import { format } from "d3-format"; import GapChartPct from "@/components/dashboard/GapChartPct.vue"; +import GapChartRate from "@/components/dashboard/GapChartRate.vue" import KeyPerformanceIndicator from "@/components/dashboard/KeyPerformanceIndicator.vue"; import { formatPct, sortByProperty } from "../../utils/utils"; import { compile } from "handlebars"; diff --git a/src/components/dashboard/GapChart.vue b/src/components/dashboard/GapChartPct.vue similarity index 98% rename from src/components/dashboard/GapChart.vue rename to src/components/dashboard/GapChartPct.vue index b35ae3b..9d4485e 100644 --- a/src/components/dashboard/GapChart.vue +++ b/src/components/dashboard/GapChartPct.vue @@ -20,7 +20,7 @@ const remSize = parseFloat(getComputedStyle(document.documentElement).fontSize); const spec = computed(() => { return { $schema: "https://vega.github.io/schema/vega/v5.json", - description: "Bar chart showing the gap in vaccinations by race", + description: "Bar chart showing the gap by race", background: "transparent", padding: { left: 0, top: 0, right: 0, bottom: -1 }, autosize: { diff --git a/src/components/dashboard/GapChartRate.vue b/src/components/dashboard/GapChartRate.vue new file mode 100644 index 0000000..e23a6da --- /dev/null +++ b/src/components/dashboard/GapChartRate.vue @@ -0,0 +1,225 @@ + + + diff --git a/src/router.ts b/src/router.ts index 5ba38d6..24a49ae 100644 --- a/src/router.ts +++ b/src/router.ts @@ -53,6 +53,11 @@ const routes: RouteRecordRaw[] = [ component: () => import("./views/historical/hospitalization-hotspot/index.vue"), }, + { + path: "testing-gap", + name: "Closing the gap in COVID-19 testing", + component: () => import("./views/historical/testing-gap/index.vue"), + }, ], }, { diff --git a/src/views/datasets/booster-gap/dashboard.vue b/src/views/datasets/booster-gap/dashboard.vue index aefe38b..7ecd496 100644 --- a/src/views/datasets/booster-gap/dashboard.vue +++ b/src/views/datasets/booster-gap/dashboard.vue @@ -110,6 +110,7 @@ ]" :focus-stat="controls.focusStat" :phrases="phrases" + :displayAsRate="false" />
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/views/historical/testing-gap/index.vue b/src/views/historical/testing-gap/index.vue new file mode 100644 index 0000000..3ead80d --- /dev/null +++ b/src/views/historical/testing-gap/index.vue @@ -0,0 +1,77 @@ + + + + + + \ No newline at end of file From 4e96bf452802acd1b57fa9961af1bb93b7a166e0 Mon Sep 17 00:00:00 2001 From: Sam Bessey Date: Tue, 26 Sep 2023 09:24:59 -0400 Subject: [PATCH 03/14] feat: make map work for either rate or pct --- src/components/base/SideBar.vue | 4 +- src/components/dashboard/ColdMap.vue | 64 +- src/components/dashboard/GapByRace.vue | 22 +- src/components/dashboard/GapChartRate.vue | 41 +- src/views/datasets/booster-gap/dashboard.vue | 6 +- src/views/datasets/vaccine-gap/dashboard.vue | 3 +- .../hospitalization-hotspot/dashboard.vue | 1 + .../historical/testing-gap/dashboard.vue | 751 +++++++++--------- src/views/historical/testing-gap/index.vue | 151 ++-- 9 files changed, 537 insertions(+), 506 deletions(-) diff --git a/src/components/base/SideBar.vue b/src/components/base/SideBar.vue index 34e2910..7920420 100644 --- a/src/components/base/SideBar.vue +++ b/src/components/base/SideBar.vue @@ -207,8 +207,8 @@ const HISTORICAL = [ { name: "Testing Gaps", route: "testing-gap", - available: true - } + available: true, + }, ]; const SPOTLIGHTS = [ diff --git a/src/components/dashboard/ColdMap.vue b/src/components/dashboard/ColdMap.vue index 6333908..be01e1b 100644 --- a/src/components/dashboard/ColdMap.vue +++ b/src/components/dashboard/ColdMap.vue @@ -21,6 +21,7 @@ const props = defineProps<{ focusStat: FocusStat; initialActiveCluster: Cluster; mapType: string; + displayAsRate: boolean; }>(); const filteredTown = computed(() => { @@ -84,12 +85,21 @@ const clusters = computed(() => { const focusFields = computed(() => { if (props.mapType === "cold") { - return { - name: props.focusStat.name, - fill: `gap_${props.focusStat.value}_pct`, - tooltip: `gap_${props.focusStat.value}`, - population: `population_${props.focusStat.value}`, - }; + if (props.displayAsRate) { + return { + name: props.focusStat.name, + fill: `per100k_${props.focusStat.value}`, + tooltip: `gap_${props.focusStat.value}`, + population: `population_${props.focusStat.value}`, + }; + } else { + return { + name: props.focusStat.name, + fill: `gap_${props.focusStat.value}_pct`, + tooltip: `gap_${props.focusStat.value}`, + population: `population_${props.focusStat.value}`, + }; + } } else { return { name: props.focusStat.name, @@ -102,19 +112,35 @@ const focusFields = computed(() => { const tooltipSignal = computed(() => { if (props.mapType === "cold") { - return `{ - title: datum.properties.name - , 'Percent gap among ${focusFields.value.name.toLowerCase()}': datum.properties.${ - focusFields.value.population - } > 0 ? format(datum.properties.${ - focusFields.value.fill - }, '.1%') : 'Not enough information' - , 'Dose gap among ${focusFields.value.name.toLowerCase()}': datum.properties.${ - focusFields.value.population - } > 0 ? datum.properties.${ - focusFields.value.tooltip - } : 'Not enough information' - }`; + if (props.displayAsRate) { + return `{ + title: datum.properties.name + , 'Gap per 100,000 among ${focusFields.value.name.toLocaleLowerCase()}': datum.properties.${ + focusFields.value.population + } > 0 ? format(datum.properties.${ + focusFields.value.fill + }, ',.0d') : 'Not enough information' + , 'Absolute gap among ${focusFields.value.name.toLowerCase()}': datum.properties.${ + focusFields.value.population + } > 0 ? datum.properties.${ + focusFields.value.tooltip + } : 'Not enough information' + }`; + } else { + return `{ + title: datum.properties.name + , 'Percent gap among ${focusFields.value.name.toLowerCase()}': datum.properties.${ + focusFields.value.population + } > 0 ? format(datum.properties.${ + focusFields.value.fill + }, '.1%') : 'Not enough information' + , 'Dose gap among ${focusFields.value.name.toLowerCase()}': datum.properties.${ + focusFields.value.population + } > 0 ? datum.properties.${ + focusFields.value.tooltip + } : 'Not enough information' + }`; + } } else { return `{ title: datum.properties.name diff --git a/src/components/dashboard/GapByRace.vue b/src/components/dashboard/GapByRace.vue index 24e3896..9e88c47 100644 --- a/src/components/dashboard/GapByRace.vue +++ b/src/components/dashboard/GapByRace.vue @@ -17,12 +17,14 @@ :class="focusStat.value === 'total' ? '' : 'is-invisible'" >
- - ; focusStat: FocusStat; phrases: Phrases; - displayAsRate: Boolean; + displayAsRate: boolean; }>(); const expected = computed( @@ -198,7 +198,7 @@ const activeStats = computed(() => { : NaN, rate: population > 0 - ? row[f.observedField] / row[f.populationField] * 100000 + ? (row[f.observedField] / row[f.populationField]) * 100000 : NaN, gap: Math.max(0, row[f.expectedField] - row[f.observedField]), population, @@ -234,12 +234,12 @@ const kpiValue = computed(() => { return activeFocusStats.value?.rate.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0, - }) + }); } else { - return formatPct(activeFocusStats.value?.pct) + return formatPct(activeFocusStats.value?.pct); } } else { - return '?' + return "?"; } }); diff --git a/src/components/dashboard/GapChartRate.vue b/src/components/dashboard/GapChartRate.vue index e23a6da..00ce6df 100644 --- a/src/components/dashboard/GapChartRate.vue +++ b/src/components/dashboard/GapChartRate.vue @@ -43,7 +43,7 @@ const spec = computed(() => { scales: [ { name: "xscale", - domain: {data: "stats", field: "rate"}, + domain: { data: "stats", field: "rate" }, range: "width", }, { @@ -101,7 +101,7 @@ const spec = computed(() => { tooltip: [ { signal: - "{ title: datum.name, 'Rate per 100,000': format(datum.rate, '.0d'), 'To Close Gap': datum.gap}", + "{ title: datum.name, 'Rate per 100,000': format(datum.rate, ',.0d'), 'To Close Gap': datum.gap}", test: "datum.population > 0", }, ], @@ -132,7 +132,7 @@ const spec = computed(() => { { signal: `{ title: datum.name, - 'Rate per 100,000': format(datum.rate, '.0d'), + 'Rate per 100,000': format(datum.rate, ',.0d'), 'To Close Gap': datum.gap }`, test: "datum.population > 0", @@ -175,27 +175,28 @@ const spec = computed(() => { align: { value: "left" }, baseline: { value: "middle" }, text: { - signal: "datum.datum.gap > 0 ? datum.datum.gap + ' to close gap' : ''", - }, - }, - }, - }, - { - type: "text", - from: { data: "bars" }, - encode: { - enter: { - x: { field: "x2", offset: -5 }, - y: { field: "y", offset: { field: "height", mult: 0.5 } }, - fill: { value: "#FFFFFF" }, - align: { value: "right" }, - baseline: { value: "middle" }, - text: { - signal: "format(datum.datum.rate, '.0d')", + signal: + "datum.datum.gap > 0 ? datum.datum.gap + ' to close gap' : ''", }, }, }, }, + // { + // type: "text", + // from: { data: "bars" }, + // encode: { + // enter: { + // x: { field: "x2", offset: -5 }, + // y: { field: "y", offset: { field: "height", mult: 0.5 } }, + // fill: { value: "#FFFFFF" }, + // align: { value: "right" }, + // baseline: { value: "middle" }, + // // text: { + // // signal: "format(datum.datum.rate, '.0d')", + // // }, + // }, + // }, + // }, { type: "rule", encode: { diff --git a/src/views/datasets/booster-gap/dashboard.vue b/src/views/datasets/booster-gap/dashboard.vue index 7ecd496..d744dd6 100644 --- a/src/views/datasets/booster-gap/dashboard.vue +++ b/src/views/datasets/booster-gap/dashboard.vue @@ -68,6 +68,7 @@ :initial-active-cluster="dashboardActiveCluster" :map-type="'cold'" class="is-absolute" + :display-as-rate="false" @new-active-cluster-id="updateCluster" />
{{ minRaceName }} residents. Only {{ pct }} of {{ minRaceName }} residents are vaccinated. Approximately {{ gap }} more {{ minRaceName }} residents need to be vaccinated to close this gap.", kpiTitle: "{{ race }} residents vaccinated in {{ name }}", - gapKpiTitle: "Approximate vaccine doses for {{ race }} residents needed to close the gap", + gapKpiTitle: + "Approximate vaccine doses for {{ race }} residents needed to close the gap", }; diff --git a/src/views/datasets/vaccine-gap/dashboard.vue b/src/views/datasets/vaccine-gap/dashboard.vue index 5db0303..4de4ea9 100644 --- a/src/views/datasets/vaccine-gap/dashboard.vue +++ b/src/views/datasets/vaccine-gap/dashboard.vue @@ -63,6 +63,7 @@ :initial-active-cluster="dashboardActiveCluster" :map-type="'cold'" class="is-absolute" + :display-as-rate="false" @new-active-cluster-id="updateCluster" />
- - - - - - - - - - - \ No newline at end of file +} + +.instructions { + position: absolute; + top: 0; + padding: 4px 4px; + margin: 6px 6px 0px; + background-color: hsl(0deg 0% 100% / 60%); + font-size: 0.875rem; + animation: fade-in 500ms ease-in-out both; + animation-delay: 250ms; +} + +.red-dot { + margin-bottom: -5px; +} + +.centered { + display: grid; + place-content: center; +} + +.invisible { + visibility: hidden; +} + diff --git a/src/views/historical/testing-gap/index.vue b/src/views/historical/testing-gap/index.vue index 3ead80d..2d07665 100644 --- a/src/views/historical/testing-gap/index.vue +++ b/src/views/historical/testing-gap/index.vue @@ -1,77 +1,76 @@ - - - - - \ No newline at end of file + + +

+ Data last updated {{ prettyDate(currentDate) }} +

+

+ Archived data from {{ prettyDate(currentDate) }} - + View latest data +

+
+ + + + + + + From eb599eef25fcb465ec7d3e6fcf37b2149b5f60df Mon Sep 17 00:00:00 2001 From: Sam Bessey Date: Tue, 26 Sep 2023 10:34:06 -0400 Subject: [PATCH 04/14] refactor: use Intl.NumberFormat() constructor for rates --- src/components/dashboard/ColdMap.vue | 10 ++--- src/components/dashboard/GapByRace.vue | 26 +++++++------ src/components/dashboard/GapChartRate.vue | 2 +- src/components/dashboard/HotspotCard.vue | 17 ++++----- src/utils/utils.ts | 5 +++ .../historical/testing-gap/dashboard.vue | 37 ++++++++----------- 6 files changed, 48 insertions(+), 49 deletions(-) diff --git a/src/components/dashboard/ColdMap.vue b/src/components/dashboard/ColdMap.vue index be01e1b..c72abd4 100644 --- a/src/components/dashboard/ColdMap.vue +++ b/src/components/dashboard/ColdMap.vue @@ -9,6 +9,7 @@ import { useVega } from "../../composables/useVega"; import RI_GEOJSON from "@/assets/geography/ri.json"; import HEZ_GEOJSON from "@/assets/geography/hez.json"; import { COLORS, COLOR_SCALES, NULL_CLUSTER } from "../../utils/constants"; +import { formatUsString } from "../../utils/utils"; import { cloneDeep } from "lodash/lang"; @@ -62,12 +63,9 @@ const clusters = computed(() => { ? (datum[`observed_${field}`] / datum[`population_${field}`]) * 100000 : 0; - additionalFields[`tooltip_${field}`] = additionalFields[ - `per100k_${field}` - ].toLocaleString("en-US", { - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }); + additionalFields[`tooltip_${field}`] = formatUsString.format( + additionalFields[`per100k_${field}`], + ); }); if (datum) { diff --git a/src/components/dashboard/GapByRace.vue b/src/components/dashboard/GapByRace.vue index 9e88c47..a239ee9 100644 --- a/src/components/dashboard/GapByRace.vue +++ b/src/components/dashboard/GapByRace.vue @@ -20,13 +20,13 @@
@@ -40,7 +40,7 @@ name: activeCluster.name, minRaceName: minVaxRace?.name, pct: formatPct(minVaxRace?.pct), - expectedPct: formatPct(expected), + expectedPct: formatPct(expectedPct), gap: minVaxRace?.gap, }), ) @@ -92,8 +92,10 @@ gapPhrase({ name: props.activeCluster.name, pct: formatPct(activeFocusStats?.pct), + rate: formatUsString.format(activeFocusStats?.rate), race: activeFocusStats?.name, - expectedPct: formatPct(expected), + expectedPct: formatPct(expectedPct), + expectedRate: formatUsString.format(expectedRate), gap: activeFocusStats?.gap, }), ) @@ -110,7 +112,7 @@ name: props.activeCluster.name, pct: formatPct(activeFocusStats?.pct), race: activeFocusStats?.name, - expectedPct: formatPct(expected), + expectedPct: formatPct(expectedPct), }), ) " @@ -155,7 +157,7 @@ import { computed } from "vue"; import GapChartPct from "@/components/dashboard/GapChartPct.vue"; import GapChartRate from "@/components/dashboard/GapChartRate.vue"; import KeyPerformanceIndicator from "@/components/dashboard/KeyPerformanceIndicator.vue"; -import { formatPct, sortByProperty } from "../../utils/utils"; +import { formatPct, sortByProperty, formatUsString } from "../../utils/utils"; import { compile } from "handlebars"; import sanitizeHtml from "sanitize-html"; @@ -168,10 +170,15 @@ const props = defineProps<{ displayAsRate: boolean; }>(); -const expected = computed( +const expectedPct = computed( () => props.stats[0].expected_total / props.stats[0].population_total, ); +const expectedRate = computed( + () => + (props.stats[0].expected_total / props.stats[0].population_total) * 100000, +); + const fieldData = computed(() => { return props.fieldNames.map((f) => ({ name: f.name, @@ -231,10 +238,7 @@ const activeFocusStats = computed(() => { const kpiValue = computed(() => { if (activeFocusStats.value?.population > 0) { if (props.displayAsRate) { - return activeFocusStats.value?.rate.toLocaleString("en-US", { - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }); + return formatUsString.format(activeFocusStats.value?.rate); } else { return formatPct(activeFocusStats.value?.pct); } diff --git a/src/components/dashboard/GapChartRate.vue b/src/components/dashboard/GapChartRate.vue index 00ce6df..acd9aca 100644 --- a/src/components/dashboard/GapChartRate.vue +++ b/src/components/dashboard/GapChartRate.vue @@ -118,7 +118,7 @@ const spec = computed(() => { encode: { enter: { x: { scale: "xscale", field: "rate" }, - x2: { scale: "xscale", value: props.expected * 100000 }, + x2: { scale: "xscale", value: props.expected }, yc: { signal: "scale('yscale', datum.name) + bandwidth('yscale') / 2", }, diff --git a/src/components/dashboard/HotspotCard.vue b/src/components/dashboard/HotspotCard.vue index a98106d..747485c 100644 --- a/src/components/dashboard/HotspotCard.vue +++ b/src/components/dashboard/HotspotCard.vue @@ -32,7 +32,7 @@ sanitizeHtml( allHighest({ name: props.activeCluster.name, - rate: round(maxRace?.rate).toLocaleString('en-US'), + rate: formatUsString.format(maxRace?.rate), }), ) " @@ -43,7 +43,7 @@ sanitizeHtml( allResidents({ name: props.activeCluster.name, - rate: round(maxRace?.rate).toLocaleString('en-US'), + rate: formatUsString.format(maxRace?.rate), maxRaceName: maxRace?.name, }), ) @@ -64,7 +64,7 @@ { export const formatPct = format(".0%"); +export const formatUsString = new Intl.NumberFormat("en-US", { + minimumFractionDigits: 0, + maximumFractionDigits: 0, +}); + export const sortByProperty = (property) => (a, b) => { let valA = a[property]; let valB = b[property]; diff --git a/src/views/historical/testing-gap/dashboard.vue b/src/views/historical/testing-gap/dashboard.vue index 7b52d03..78512e9 100644 --- a/src/views/historical/testing-gap/dashboard.vue +++ b/src/views/historical/testing-gap/dashboard.vue @@ -1,16 +1,12 @@
- +