From 03287ad104d9e3826c1e2a69ac28a949661eb7ac Mon Sep 17 00:00:00 2001 From: Samuel Gratzl Date: Thu, 6 Jan 2022 10:23:24 -0500 Subject: [PATCH 1/9] feat: start with multi line charts --- src/modes/dashboard/WidgetConfigurator.svelte | 4 + src/modes/dashboard/WidgetFactory.svelte | 26 +++ .../dashboard/config/RegionsPicker.svelte | 45 ++++ src/modes/dashboard/configResolver.ts | 63 +++++- src/modes/dashboard/state.ts | 8 +- .../widgets/RegionLineChartWidget.svelte | 211 ++++++++++++++++++ .../widgets/SensorLineChartWidget.svelte | 211 ++++++++++++++++++ 7 files changed, 564 insertions(+), 4 deletions(-) create mode 100644 src/modes/dashboard/config/RegionsPicker.svelte create mode 100644 src/modes/dashboard/widgets/RegionLineChartWidget.svelte create mode 100644 src/modes/dashboard/widgets/SensorLineChartWidget.svelte diff --git a/src/modes/dashboard/WidgetConfigurator.svelte b/src/modes/dashboard/WidgetConfigurator.svelte index c0c628b0..1ea0db8c 100644 --- a/src/modes/dashboard/WidgetConfigurator.svelte +++ b/src/modes/dashboard/WidgetConfigurator.svelte @@ -4,6 +4,7 @@ import RegionPicker from './config/RegionPicker.svelte'; import SensorPicker from './config/SensorPicker.svelte'; import SensorsPicker from './config/SensorsPicker.svelte'; + import RegionsPicker from './config/RegionsPicker.svelte'; import TimeFramePicker from './config/TimeFramePicker.svelte'; import { findWidget } from './state'; @@ -35,6 +36,9 @@ {#if hasConfig.has('region')} {/if} +{#if hasConfig.has('regions')} + +{/if} {#if hasConfig.has('level')} {/if} diff --git a/src/modes/dashboard/WidgetFactory.svelte b/src/modes/dashboard/WidgetFactory.svelte index 784ed111..e620b6b7 100644 --- a/src/modes/dashboard/WidgetFactory.svelte +++ b/src/modes/dashboard/WidgetFactory.svelte @@ -10,12 +10,16 @@ import SensorTableWidget from './widgets/SensorTableWidget.svelte'; import AnomaliesWidget from './widgets/AnomaliesWidget.svelte'; import ZoomedMapChartWidget from './widgets/ZoomedMapChartWidget.svelte'; + import SensorLineChartWidget from './widgets/SensorLineChartWidget.svelte'; + import RegionLineChartWidget from './widgets/RegionLineChartWidget.svelte'; import { resolveDate, resolveRegion, resolveRegionLevel, + resolveRegions, resolveSensor, resolveSensors, + resolveSensorParams, resolveTimeFrame, } from './configResolver'; @@ -54,6 +58,28 @@ id={c.id} initialState={c.state} /> +{:else if c.type === 'sensorsline'} + +{:else if c.type === 'regionsline'} + {:else if c.type === 'map'} + import RegionSearch from '../../../components/RegionSearch.svelte'; + import { getInfoByName } from '../../../data/regions'; + import { sortedNameInfos } from '../utils'; + + /** + * @type {import("../../../stores/params").RegionParam} + */ + export let region; + + /** + * @type {string[]} + */ + export let value = []; + + let syncedValues = value ? (Array.isArray(value) ? value : [value]) : ['']; + + $: defaultRegion = { + id: '', + displayName: `Use Configured: ${region.displayName}`, + }; + $: allItems = [defaultRegion, ...sortedNameInfos]; + + +
+ + {#each syncedValues as s} + + {/each} + getInfoByName(v)) : [defaultRegion]} + on:change={(e) => { + syncedValues = e.detail ? [`${e.detail.id}@${e.detail.level}`] : ['']; + }} + on:add={(e) => { + if (syncedValues.length === 1 && syncedValues[0] === '') { + // replace default + syncedValues = [`${e.detail.id}@${e.detail.level}`]; + } else { + syncedValues = [...syncedValues, `${e.detail.id}@${e.detail.level}`]; + } + }} + /> +
diff --git a/src/modes/dashboard/configResolver.ts b/src/modes/dashboard/configResolver.ts index f2f94bb9..7deeefd2 100644 --- a/src/modes/dashboard/configResolver.ts +++ b/src/modes/dashboard/configResolver.ts @@ -1,6 +1,13 @@ import { parseAPITime } from '../../data'; -import { getInfoByName } from '../../data/regions'; -import { DateParam, RegionLevel, RegionParam, Sensor, SensorParam, TimeFrame } from '../../stores/params'; +import { + getInfoByName, + nationInfo, + getHHSRegionOfState, + getStateOfCounty, + StateInfo, + CountyInfo, +} from '../../data/regions'; +import { DateParam, Region, RegionLevel, RegionParam, Sensor, SensorParam, TimeFrame } from '../../stores/params'; export function resolveSensor(defaultSensor: SensorParam, key?: string): SensorParam { if (!key) { @@ -29,6 +36,25 @@ export function resolveSensors(defaultSensor: SensorParam, keys?: readonly strin return keys.map((k) => defaultSensor.manager.getSensor(k)).filter((d): d is Sensor => d != null); } +export function resolveSensorParams(defaultSensor: SensorParam, keys?: readonly string[]): SensorParam[] { + const CASES = new SensorParam(defaultSensor.manager.getDefaultCasesSignal()!, defaultSensor.manager); + const DEATHS = new SensorParam(defaultSensor.manager.getDefaultDeathSignal()!, defaultSensor.manager); + + if (!keys) { + if (defaultSensor.key === CASES.key || defaultSensor.key === DEATHS.key) { + return [CASES, DEATHS]; + } + return [defaultSensor, CASES, DEATHS]; + } + if (typeof keys === 'string') { + return [resolveSensor(defaultSensor, keys)]; + } + return keys + .map((k) => defaultSensor.manager.getSensor(k)) + .filter((d): d is Sensor => d != null) + .map((s) => new SensorParam(s, defaultSensor.manager)); +} + export function resolveRegion(defaultRegion: RegionParam, r?: string): RegionParam { if (!r) { return defaultRegion; @@ -40,6 +66,39 @@ export function resolveRegion(defaultRegion: RegionParam, r?: string): RegionPar return new RegionParam(rr); } +export function resolveRegions(defaultRegion: RegionParam, r?: string): RegionParam[] { + const regions: RegionParam[] = []; + if (!r) { + // derive default + regions.push(defaultRegion); + if (defaultRegion.level === 'nation') { + return regions; + } + if (defaultRegion.level === 'state') { + const hhs = getHHSRegionOfState(defaultRegion.value as StateInfo); + if (hhs) { + regions.push(new RegionParam(hhs)); + } + } + if (defaultRegion.level === 'county') { + const state = getStateOfCounty(defaultRegion.value as CountyInfo); + if (state) { + regions.push(new RegionParam(state)); + } + } + regions.push(new RegionParam(nationInfo)); + return regions; + } + if (typeof r === 'string') { + const rr = getInfoByName(r); + return rr ? [new RegionParam(rr)] : [defaultRegion]; + } + return (r as string[]) + .map((ri) => getInfoByName(ri)) + .filter((r): r is Region => r != null) + .map((r) => new RegionParam(r)); +} + export function resolveRegionLevel(defaultRegion: RegionParam, level?: RegionLevel): RegionLevel { if (!level) { return defaultRegion.level; diff --git a/src/modes/dashboard/state.ts b/src/modes/dashboard/state.ts index 992594d4..f7ae28c3 100644 --- a/src/modes/dashboard/state.ts +++ b/src/modes/dashboard/state.ts @@ -13,11 +13,13 @@ export type WidgetType = | 'regionpcp' | 'datepcp' | 'anomalies' - | 'zoomedmap'; + | 'zoomedmap' + | 'sensorsline' + | 'regionsline'; export type WidgetFocus = 'time' | 'region' | 'indicator'; export type WidgetCategory = 'chart' | 'table' | 'simple' | 'advanced'; -export type WidgetConfigOption = 'sensor' | 'region' | 'timeFrame' | 'date' | 'level' | 'sensors'; +export type WidgetConfigOption = 'sensor' | 'region' | 'timeFrame' | 'date' | 'level' | 'sensors' | 'regions'; export interface Widget { id: WidgetType; @@ -88,6 +90,8 @@ function asWidget( export const widgets: readonly Widget[] = [ asWidget('line', 'Time Series', ['time'], 'chart', ['sensor', 'region', 'timeFrame']), + asWidget('sensorsline', 'Multi Indicator Time Series', ['time'], 'chart', ['sensors', 'region', 'timeFrame']), + asWidget('regionsline', 'Multi Region Time Series', ['time'], 'chart', ['sensor', 'regions', 'timeFrame']), asWidget('map', 'Choropleth Map', ['region'], 'chart', ['sensor', 'level', 'date']), asWidget('zoomedmap', 'Zoomed Choropleth Map', ['region'], 'chart', ['sensor', 'region', 'date']), asWidget('hex', 'Hexagon Map', ['region'], 'chart', ['sensor', 'date']), diff --git a/src/modes/dashboard/widgets/RegionLineChartWidget.svelte b/src/modes/dashboard/widgets/RegionLineChartWidget.svelte new file mode 100644 index 00000000..d8ade203 --- /dev/null +++ b/src/modes/dashboard/widgets/RegionLineChartWidget.svelte @@ -0,0 +1,211 @@ + + + + + + + + + + diff --git a/src/modes/dashboard/widgets/SensorLineChartWidget.svelte b/src/modes/dashboard/widgets/SensorLineChartWidget.svelte new file mode 100644 index 00000000..2256ff40 --- /dev/null +++ b/src/modes/dashboard/widgets/SensorLineChartWidget.svelte @@ -0,0 +1,211 @@ + + + + + + + + + + From 8c74f2444696c77dc683527c2341a76231ba2996 Mon Sep 17 00:00:00 2001 From: Samuel Gratzl Date: Fri, 7 Jan 2022 09:00:47 -0500 Subject: [PATCH 2/9] feat: prepare for axis determination --- src/data/sensor.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/data/sensor.ts b/src/data/sensor.ts index c63692f9..275ba4ff 100644 --- a/src/data/sensor.ts +++ b/src/data/sensor.ts @@ -211,3 +211,40 @@ export function resolveDefaultRegion(sensor: Sensor): Region { } return nationInfo; } + +/** + * checks whether multiple regions of the same sensor can be shown on a single axis without harm + * @param sensor + * @returns + */ +export function isComparableAcrossRegions(sensor: Sensor): boolean { + return sensor.format === 'fraction' || sensor.format === 'per100k' || sensor.format === 'percent'; +} + +/** + * groups sensor by their compatibility, i.e. can be shown on the same axis + */ +export function toSignalCompatibilityGroups(sensors: readonly Sensor[]): Sensor[][] { + if (sensors.length === 1) { + return [sensors.slice()]; + } + const byFormat = new Map(); + const byFormatList: Sensor[][] = []; + for (const sensor of sensors) { + if (isComparableAcrossRegions(sensor)) { + // can combine with others of same format + const entry = byFormat.get(sensor.format); + if (entry) { + entry.push(sensor); + } else { + const list = [sensor]; + byFormatList.push(list); + byFormat.set(sensor.format, list); + } + } else { + // need to be alone + byFormatList.push([sensor]); + } + } + return byFormatList; +} From c7a2e50c67042f1afb03ba7f94d6fe3f680c040e Mon Sep 17 00:00:00 2001 From: Samuel Gratzl Date: Fri, 7 Jan 2022 09:27:43 -0500 Subject: [PATCH 3/9] feat: basic multi region line chart --- .../dashboard/config/RegionsPicker.svelte | 12 +++- .../dashboard/config/SensorsPicker.svelte | 2 +- .../widgets/RegionLineChartWidget.svelte | 67 ++++++++----------- src/specs/lineSpec.ts | 18 ++++- 4 files changed, 56 insertions(+), 43 deletions(-) diff --git a/src/modes/dashboard/config/RegionsPicker.svelte b/src/modes/dashboard/config/RegionsPicker.svelte index 57eef050..9a6b6481 100644 --- a/src/modes/dashboard/config/RegionsPicker.svelte +++ b/src/modes/dashboard/config/RegionsPicker.svelte @@ -20,16 +20,18 @@ displayName: `Use Configured: ${region.displayName}`, }; $: allItems = [defaultRegion, ...sortedNameInfos]; + + $: selectedItems = syncedValues.map((d) => (!d ? defaultRegion : getInfoByName(d))); -
+
{#each syncedValues as s} {/each} getInfoByName(v)) : [defaultRegion]} + {selectedItems} on:change={(e) => { syncedValues = e.detail ? [`${e.detail.id}@${e.detail.level}`] : ['']; }} @@ -43,3 +45,9 @@ }} />
+ + diff --git a/src/modes/dashboard/config/SensorsPicker.svelte b/src/modes/dashboard/config/SensorsPicker.svelte index 63c28f80..1d533675 100644 --- a/src/modes/dashboard/config/SensorsPicker.svelte +++ b/src/modes/dashboard/config/SensorsPicker.svelte @@ -48,7 +48,7 @@
diff --git a/src/modes/dashboard/widgets/RegionLineChartWidget.svelte b/src/modes/dashboard/widgets/RegionLineChartWidget.svelte index d8ade203..356f466a 100644 --- a/src/modes/dashboard/widgets/RegionLineChartWidget.svelte +++ b/src/modes/dashboard/widgets/RegionLineChartWidget.svelte @@ -11,18 +11,13 @@ import WidgetCard, { DEFAULT_WIDGET_STATE } from './WidgetCard.svelte'; import { getContext } from 'svelte'; import DownloadMenu from '../../../components/DownloadMenu.svelte'; - import { - generateLineChartSpec, - genAnnotationLayer, - resolveHighlightedDate, - patchHighlightTuple, - } from '../../../specs/lineSpec'; - import { annotationManager, getLevelInfo } from '../../../stores'; + import { generateCompareLineSpec, resolveHighlightedDate, patchHighlightTuple } from '../../../specs/lineSpec'; import { formatDateISO, formatWeek } from '../../../formats'; import { WidgetHighlight } from '../highlight'; import isEqual from 'lodash-es/isEqual'; import { createEventDispatcher } from 'svelte'; import { EpiWeek } from '../../../data/EpiWeek'; + import { isComparableAcrossRegions } from '../../../data/sensor'; const dispatch = createEventDispatcher(); @@ -48,6 +43,9 @@ export let initialState = DEFAULT_STATE; + $: canCompare = isComparableAcrossRegions(sensor.value); + $: visibleRegions = canCompare ? regions : regions.slice(0, Math.min(2, regions.length)); + let superState = {}; $: state = { ...initialState, @@ -74,69 +72,62 @@ /** * @param {import('../../../stores/params').SensorParam} sensor - * @param {import('../../../stores/params').RegionParam} region + * @param {import('../../../stores/params').RegionParam[]} regions * @param {import('../../../stores/params').TimeFrame} timeFrame * @param {{zero: boolean, raw: boolean}} options */ - function genSpec(sensor, region, timeFrame) { + function genSpec(sensor, regions, timeFrame) { const isWeekly = sensor.value.isWeeklySignal; /** * @type {import('../../../specs/lineSpec').LineSpecOptions} */ const options = { initialDate: highlightToDate(highlight) || timeFrame.max, - color: getLevelInfo(region.level).color, domain: timeFrame.domain, zero: false, valueFormat: sensor.value.formatSpecifier, xTitle: sensor.xAxis, - title: [`${sensor.name} in ${region.displayName}`, timeFrame.toNiceString(isWeekly)], + title: [`${sensor.name}`, timeFrame.toNiceString(isWeekly)], subTitle: sensor.unit, highlightRegion: false, clearHighlight: false, autoAlignOffset: 60, paddingTop: 80, isWeeklySignal: isWeekly, + legend: true, + compareField: 'displayName', }; - return generateLineChartSpec(options); + return generateCompareLineSpec( + regions.map((region) => region.displayName), + options, + ); } /** - * @param {import("../../stores/params").SensorParam} sensor - * @param {import("../../stores/params").DateParam} date - * @param {import("../../stores/params").TimeFrame} timeFrame + * @param {import("../../../stores/params").SensorParam} sensor + * @param {import("../../../stores/params").RegionParam[]} regions + * @param {import("../../../stores/params").TimeFrame} timeFrame * @param {boolean} raw */ - function loadData(sensor, region, timeFrame) { - const selfData = fetcher.fetch1Sensor1RegionNDates(sensor, region, timeFrame); - return selfData; + function loadData(sensor, regions, timeFrame) { + return fetcher.fetch1SensorNRegionsNDates(sensor, regions, timeFrame); } /** - * @param {import("../../stores/params").SensorParam} sensor - * @param {import("../../stores/params").Region region + * @param {import("../../../stores/params").SensorParam} sensor + * @param {import("../../../stores/params").Region region */ - function generateFileName(sensor, region, timeFrame) { - const regionName = `${region.propertyId}-${region.displayName}`; + function generateFileName(sensor, regions, timeFrame) { + const regionName = regions.map((region) => `${region.propertyId}-${region.displayName}`); let suffix = ''; return `${sensor.name}_${regionName}_${ sensor.isWeeklySignal ? formatWeek(timeFrame.min_week) : formatDateISO(timeFrame.min) }-${sensor.isWeeklySignal ? formatWeek(timeFrame.max_week) : formatDateISO(timeFrame.max)}${suffix}`; } - function injectRanges(spec, timeFrame, annotations) { - if (annotations.length > 0) { - spec.layer.unshift(genAnnotationLayer(annotations, timeFrame)); - } - return spec; - } - - $: region = regions[0]; - - $: annotations = $annotationManager.getWindowAnnotations(sensor, region, timeFrame.min, timeFrame.max); - $: spec = injectRanges(genSpec(sensor, region, timeFrame), timeFrame, annotations); - $: data = loadData(sensor, region, timeFrame); - $: fileName = generateFileName(sensor, region, timeFrame); + $: spec = genSpec(sensor, visibleRegions, timeFrame); + $: data = loadData(sensor, visibleRegions, timeFrame); + $: fileName = generateFileName(sensor, visibleRegions, timeFrame); let vegaRef = null; @@ -144,7 +135,7 @@ const date = resolveHighlightedDate(event); const newHighlight = new WidgetHighlight( sensor.value, - region.value, + visibleRegions.map((r) => r.value), sensor.isWeeklySignal ? EpiWeek.fromDate(date) : date, ); if (!newHighlight.equals(highlight)) { @@ -152,7 +143,7 @@ } } - $: highlighted = highlight != null && highlight.matches(sensor.value, region.value, timeFrame); + $: highlighted = highlight != null && highlight.matches(sensor.value, visibleRegions[0], timeFrame); function updateVegaHighlight(highlight) { if (!vegaRef) { @@ -188,7 +179,7 @@ {initialState} defaultState={DEFAULT_STATE} {highlighted} - region={regions.length === 1 ? region : 'Regions'} + region={visibleRegions.length === 1 ? visibleRegions[0] : 'Regions'} {sensor} date={timeFrame} {id} diff --git a/src/specs/lineSpec.ts b/src/specs/lineSpec.ts index 81a1cdcd..4a5824a0 100644 --- a/src/specs/lineSpec.ts +++ b/src/specs/lineSpec.ts @@ -553,7 +553,11 @@ export function generateLineChartSpec({ export function generateCompareLineSpec( compare: string[], - { compareField = 'displayName', ...options }: LineSpecOptions & { compareField?: string } = {}, + { + compareField = 'displayName', + legend = false, + ...options + }: LineSpecOptions & { compareField?: string; legend?: boolean } = {}, ): TopLevelSpec { const spec = generateLineChartSpec(options); spec.layer[0].encoding!.color = { @@ -563,8 +567,18 @@ export function generateCompareLineSpec( domain: compare, range: MULTI_COLORS, }, - legend: null, + legend: legend + ? { + direction: 'horizontal', + orient: 'bottom', + title: null, + symbolType: 'stroke', + } + : null, }; + if (legend) { + (spec.padding! as { bottom: number }).bottom = 66; + } spec.layer[1].encoding!.color = { field: compareField, type: 'nominal', From a498321da9b236e1f81e4d0b63374e10bdd6d8f0 Mon Sep 17 00:00:00 2001 From: Samuel Gratzl Date: Fri, 7 Jan 2022 09:55:36 -0500 Subject: [PATCH 4/9] feat: tune regions/sensors search --- src/components/RegionSearch.svelte | 2 ++ src/components/Search.svelte | 22 ++++++++++++------- src/components/SensorSearch.svelte | 2 ++ .../dashboard/config/RegionsPicker.svelte | 18 ++++++++++++--- .../dashboard/config/SensorsPicker.svelte | 7 ++++++ src/modes/dashboard/configResolver.ts | 16 +++++++------- 6 files changed, 48 insertions(+), 19 deletions(-) diff --git a/src/components/RegionSearch.svelte b/src/components/RegionSearch.svelte index 7adf6629..efb88503 100644 --- a/src/components/RegionSearch.svelte +++ b/src/components/RegionSearch.svelte @@ -27,11 +27,13 @@ selectOnClick {selectedItem} {selectedItems} + clear={selectedItems == null} labelFieldName="displayName" keywordFunction={combineKeywords} maxItemsToShowInList={15} on:change on:add + on:remove > diff --git a/src/components/Search.svelte b/src/components/Search.svelte index 416fb65f..db3a5e16 100644 --- a/src/components/Search.svelte +++ b/src/components/Search.svelte @@ -334,6 +334,7 @@ /> {#if clear}