From 3c887904cb57a31eef1e301518b4e574148df729 Mon Sep 17 00:00:00 2001 From: David Mears Date: Wed, 10 Dec 2025 12:16:26 +0000 Subject: [PATCH 01/20] Cache data fetches --- src/composables/useData.ts | 28 +++++++++++++++++--------- tests/unit/composables/useData.spec.ts | 8 +++----- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/composables/useData.ts b/src/composables/useData.ts index a186921..927a7a8 100644 --- a/src/composables/useData.ts +++ b/src/composables/useData.ts @@ -1,7 +1,7 @@ import { useAppStore } from "@/stores/appStore"; import { type DataRow, Dimensions, LocResolutions } from "@/types"; import { debounce } from "perfect-debounce"; -import { computed, ref, watch } from "vue"; +import { computed, ref, shallowRef, watch } from "vue"; import countryOptions from '@/data/options/countryOptions.json'; import subregionOptions from '@/data/options/subregionOptions.json'; @@ -12,6 +12,7 @@ export default () => { const fetchErrors = ref<{ e: Error, message: string }[]>([]); const histogramData = ref([]); + const histogramDataCache = shallowRef>({}); // The geographical resolutions to use based on current exploreBy and focus selections. // This is currently exposed by the composable but that's only for manual testing purposes. @@ -54,18 +55,25 @@ export default () => { }); // Fetch and parse multiple JSONs, and merge together all data. - // TODO: cacheing strategies, e.g. if the new histDataPaths are a superset of the previous ones, only load the new paths const loadDataFromPaths = async (paths: string[]) => { fetchErrors.value = []; - const allRows = await Promise.all(paths.map(async (path) => { - const response = await fetch(`${dataDir}/${path}`); - const rows = await response.json(); - return rows; - })).catch((error) => { - fetchErrors.value.push({ e: error, message: `Error loading data from paths: ${paths.join(", ")}. ${error}` }); - }); + await Promise.all(paths.map(async (path) => { + if (!histogramDataCache.value[path]) { + try { + const response = await fetch(`${dataDir}/${path}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const rows = await response.json(); + histogramDataCache.value[path] = rows; + return rows; + } catch (error) { + fetchErrors.value.push({ e: error as Error, message: `Error loading data from path: ${path}. ${error}` }); + } + } + })); - histogramData.value = allRows?.flat() ?? []; + histogramData.value = paths.flatMap((path) => histogramDataCache.value[path] || []); }; const doLoadData = debounce(async () => { diff --git a/tests/unit/composables/useData.spec.ts b/tests/unit/composables/useData.spec.ts index f61b783..9830791 100644 --- a/tests/unit/composables/useData.spec.ts +++ b/tests/unit/composables/useData.spec.ts @@ -69,10 +69,9 @@ describe('useData', () => { // Change options: round 2 appStore.exploreBy = "disease"; - expectedFetches += 2; await vi.waitFor(() => { expect(appStore.focus).toEqual("Cholera") - expect(fetchSpy).toBeCalledTimes(expectedFetches); + expect(fetchSpy).toBeCalledTimes(expectedFetches); // No increment in expectedFetches due to cacheing. }); appStore.focus = "Measles"; expectedFetches += 2; @@ -92,10 +91,9 @@ describe('useData', () => { // Change options: round 3 appStore.exploreBy = "location"; - expectedFetches += 1; await vi.waitFor(() => { expect(appStore.focus).toEqual("global") - expect(fetchSpy).toBeCalledTimes(expectedFetches); + expect(fetchSpy).toBeCalledTimes(expectedFetches); // No increment in expectedFetches due to cacheing. }); appStore.focus = "AFG"; expectedFetches += 3; @@ -129,7 +127,7 @@ describe('useData', () => { await vi.waitFor(() => { expect(fetchSpy).toBeCalled(); expect(fetchErrors.value).toEqual([expect.objectContaining( - { message: `Error loading data from paths: hist_counts_deaths_disease_log.json. TypeError: Failed to fetch` } + { message: `Error loading data from path: hist_counts_deaths_disease_log.json. TypeError: Failed to fetch` } )]); }); From f713b4c2f91f38ee9f6f16fd143077c3597fa5ca Mon Sep 17 00:00:00 2001 From: David Mears Date: Wed, 10 Dec 2025 12:21:22 +0000 Subject: [PATCH 02/20] Update unit tests --- tests/unit/composables/useData.spec.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/unit/composables/useData.spec.ts b/tests/unit/composables/useData.spec.ts index 9830791..adfda3f 100644 --- a/tests/unit/composables/useData.spec.ts +++ b/tests/unit/composables/useData.spec.ts @@ -133,4 +133,25 @@ describe('useData', () => { expect(histogramData.value).toEqual([]); }); + + it('should handle non-OK HTTP statuses gracefully', async () => { + server.use( + http.get("/data/json/hist_counts_deaths_disease_log.json", async () => { + return HttpResponse.json(null, { status: 404 }); + }), + ); + const { histogramData, fetchErrors } = useData(); + + expect(fetchErrors.value).toEqual([]); + + const fetchSpy = vi.spyOn(global, 'fetch') + await vi.waitFor(() => { + expect(fetchSpy).toBeCalled(); + expect(fetchErrors.value).toEqual([expect.objectContaining( + { message: `Error loading data from path: hist_counts_deaths_disease_log.json. Error: HTTP 404: Not Found` } + )]); + }); + + expect(histogramData.value).toEqual([]); + }); }); From 892f92d59f428fcd4123e428be5240a4fec1fd86 Mon Sep 17 00:00:00 2001 From: David Mears Date: Fri, 12 Dec 2025 17:15:51 +0000 Subject: [PATCH 03/20] Use shallowRef for data and non-reactive dict for cache --- src/composables/useData.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/composables/useData.ts b/src/composables/useData.ts index 927a7a8..2a0ae64 100644 --- a/src/composables/useData.ts +++ b/src/composables/useData.ts @@ -11,8 +11,8 @@ export default () => { const appStore = useAppStore(); const fetchErrors = ref<{ e: Error, message: string }[]>([]); - const histogramData = ref([]); - const histogramDataCache = shallowRef>({}); + const histogramData = shallowRef([]); + const histogramDataCache: Record = {}; // The geographical resolutions to use based on current exploreBy and focus selections. // This is currently exposed by the composable but that's only for manual testing purposes. @@ -58,14 +58,14 @@ export default () => { const loadDataFromPaths = async (paths: string[]) => { fetchErrors.value = []; await Promise.all(paths.map(async (path) => { - if (!histogramDataCache.value[path]) { + if (!histogramDataCache[path]) { try { const response = await fetch(`${dataDir}/${path}`); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const rows = await response.json(); - histogramDataCache.value[path] = rows; + histogramDataCache[path] = rows; return rows; } catch (error) { fetchErrors.value.push({ e: error as Error, message: `Error loading data from path: ${path}. ${error}` }); @@ -73,7 +73,7 @@ export default () => { } })); - histogramData.value = paths.flatMap((path) => histogramDataCache.value[path] || []); + histogramData.value = paths.flatMap((path) => histogramDataCache[path] || []); }; const doLoadData = debounce(async () => { From dda3494c17d74509162eff441a0446bf887258c0 Mon Sep 17 00:00:00 2001 From: David Mears Date: Fri, 12 Dec 2025 17:22:38 +0000 Subject: [PATCH 04/20] Use a pinia store instead of a composable for data --- src/components/RidgelinePlot.vue | 13 ++++---- .../useData.ts => stores/dataStore.ts} | 6 ++-- tests/unit/mocks/handlers.ts | 2 +- .../dataStore.spec.ts} | 32 +++++++++---------- 4 files changed, 27 insertions(+), 26 deletions(-) rename src/{composables/useData.ts => stores/dataStore.ts} (97%) rename tests/unit/{composables/useData.spec.ts => stores/dataStore.spec.ts} (87%) diff --git a/src/components/RidgelinePlot.vue b/src/components/RidgelinePlot.vue index 1cc96fb..251e6ca 100644 --- a/src/components/RidgelinePlot.vue +++ b/src/components/RidgelinePlot.vue @@ -3,7 +3,7 @@ id="chartWrapper" class="m-50" :data-test="JSON.stringify({ - histogramDataRowCount: histogramData.length, + histogramDataRowCount: dataStore.histogramData.length, x: appStore.dimensions.x, y: appStore.dimensions.y, withinBand: appStore.dimensions.withinBand, @@ -12,21 +12,20 @@

If a plot were plotted, it would have:

{{ key }} axis: {{ value ?? "none" }}

- Location resolutions in use: {{ geographicalResolutions.join(", ") }} + Location resolutions in use: {{ dataStore.geographicalResolutions.join(", ") }}

-

Data rows: {{ histogramData.length }}

-

Errors: {{ fetchErrors }}

+

Data rows: {{ dataStore.histogramData.length }}

+

Errors: {{ dataStore.fetchErrors }}

diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 20771a8..221a8b1 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -1,7 +1,10 @@ -import { BurdenMetrics, Dimensions, LocResolutions } from "@/types"; -import diseaseOptions from '@/data/options/diseaseOptions.json'; import { defineStore } from "pinia"; import { computed, ref, watch } from "vue"; +import { getSubregionFromCountry } from "@/utils/regions" +import { BurdenMetrics, Dimensions, LocResolutions } from "@/types"; +import countryOptions from '@/data/options/countryOptions.json'; +import subregionOptions from '@/data/options/subregionOptions.json'; +import diseaseOptions from '@/data/options/diseaseOptions.json'; const metricOptions = [ { label: "DALYs averted", value: BurdenMetrics.DALYS }, @@ -32,6 +35,11 @@ export const useAppStore = defineStore("app", () => { const exploreBy = ref(Dimensions.LOCATION); const focus = ref(LocResolutions.GLOBAL); + const filters = ref({ + [Dimensions.LOCATION]: [] as string[], + [Dimensions.DISEASE]: [] as string[], + }); + const exploreByLabel = computed(() => { const option = exploreOptions.find(o => o.value === exploreBy.value); return option ? option.label : ""; @@ -44,6 +52,36 @@ export const useAppStore = defineStore("app", () => { withinBand: withinBandAxis.value })); + // The geographical resolutions to use based on current exploreBy and focus selections. + const geographicalResolutions = computed(() => { + if (exploreBy.value === Dimensions.DISEASE) { + return [LocResolutions.SUBREGION, LocResolutions.GLOBAL]; + } else { + if (focus.value === LocResolutions.GLOBAL) { + return [LocResolutions.GLOBAL]; + } else if (subregionOptions.find(o => o.value === focus.value)) { + return [LocResolutions.SUBREGION, LocResolutions.GLOBAL]; + } else if (countryOptions.find(o => o.value === focus.value)) { + return [LocResolutions.COUNTRY, LocResolutions.SUBREGION, LocResolutions.GLOBAL]; + } + // The following line should never be able to be evaluated, because exploreBy is always either + // 'disease' or 'location', and the three possible types of location are covered by the branches. + throw new Error(`Invalid focus selection '${focus.value}' for exploreBy '${exploreBy.value}'`); + } + }); + + const getLocationForGeographicalResolution = (geog: LocResolutions) => { + switch (geog) { + case LocResolutions.GLOBAL: + return LocResolutions.GLOBAL; + case LocResolutions.SUBREGION: + return subregionOptions.find(o => o.value === focus.value)?.value + ?? getSubregionFromCountry(focus.value); + case LocResolutions.COUNTRY: + return focus.value; + } + } + watch(exploreBy, () => { if (exploreBy.value === Dimensions.DISEASE && diseaseOptions[0]) { focus.value = diseaseOptions[0].value; @@ -52,12 +90,19 @@ export const useAppStore = defineStore("app", () => { }; }); + // TODO: watch focusIsADisease instead? Then filters could be computed from that + focus? watch(focus, () => { const focusIsADisease = diseaseOptions.find(d => d.value === focus.value); if (focusIsADisease) { + filters.value[Dimensions.DISEASE] = [focus.value]; + filters.value[Dimensions.LOCATION] = subregionOptions.map(o => o.value).concat([LocResolutions.GLOBAL]); + yCategoricalAxis.value = Dimensions.LOCATION; withinBandAxis.value = Dimensions.DISEASE; } else { + filters.value[Dimensions.DISEASE] = diseaseOptions.map(d => d.value); + filters.value[Dimensions.LOCATION] = geographicalResolutions.value.map(getLocationForGeographicalResolution); + // This is only one possible way of 'focusing' on a 'location': // diseases as categorical Y axis, each row with up to 3 ridges. // An alternative would be to have the 3 location rows laid out on the categorical Y axis, @@ -75,7 +120,9 @@ export const useAppStore = defineStore("app", () => { exploreBy, exploreByLabel, exploreOptions, + filters, focus, + geographicalResolutions, logScaleEnabled, metricOptions, splitByActivityType, diff --git a/src/stores/dataStore.ts b/src/stores/dataStore.ts index 360c7e9..d9aca54 100644 --- a/src/stores/dataStore.ts +++ b/src/stores/dataStore.ts @@ -1,11 +1,8 @@ -import { useAppStore } from "@/stores/appStore"; -import { type DataRow, Dimensions, LocResolutions } from "@/types"; import { debounce } from "perfect-debounce"; import { computed, ref, shallowRef, watch } from "vue"; - -import countryOptions from '@/data/options/countryOptions.json'; -import subregionOptions from '@/data/options/subregionOptions.json'; import { defineStore } from "pinia"; +import { useAppStore } from "@/stores/appStore"; +import { type HistDataRow, Dimensions, LocResolutions } from "@/types"; export const dataDir = `/data/json` @@ -13,31 +10,12 @@ export const useDataStore = defineStore("data", () => { const appStore = useAppStore(); const fetchErrors = ref<{ e: Error, message: string }[]>([]); - const histogramData = shallowRef([]); - const histogramDataCache: Record = {}; - - // The geographical resolutions to use based on current exploreBy and focus selections. - // This is currently exposed by the composable but that's only for manual testing purposes. - const geographicalResolutions = computed(() => { - if (appStore.exploreBy === Dimensions.DISEASE) { - return [LocResolutions.SUBREGION, LocResolutions.GLOBAL]; - } else { - if (appStore.focus === LocResolutions.GLOBAL) { - return [LocResolutions.GLOBAL]; - } else if (subregionOptions.find(o => o.value === appStore.focus)) { - return [LocResolutions.SUBREGION, LocResolutions.GLOBAL]; - } else if (countryOptions.find(o => o.value === appStore.focus)) { - return [LocResolutions.COUNTRY, LocResolutions.SUBREGION, LocResolutions.GLOBAL]; - } - // The following line should never be able to be evaluated, because exploreBy is always either - // 'disease' or 'location', and the three possible types of location are covered by the branches. - throw new Error(`Invalid focus selection '${appStore.focus}' for exploreBy '${appStore.exploreBy}'`); - } - }); + const histogramData = shallowRef([]); + const histogramDataCache: Record = {}; const histogramDataPaths = computed(() => { // When we are using multiple geographical resolutions, we need to load multiple data files, to be merged together later. - return geographicalResolutions.value.map((geog) => { + return appStore.geographicalResolutions.map((geog) => { const fileNameParts = ["hist_counts", appStore.burdenMetric, "disease"]; // NB files containing 'global' data simply omit location from the file name (as they have no location stratification). if (geog === LocResolutions.SUBREGION) { @@ -75,7 +53,17 @@ export const useDataStore = defineStore("data", () => { } })); - histogramData.value = paths.flatMap((path) => histogramDataCache[path] || []); + histogramData.value = paths.flatMap((path) => histogramDataCache[path] || []).map((row) => { + // Collapse all geographic columns into one 'location' column + if (row[LocResolutions.COUNTRY]) { + row[Dimensions.LOCATION] = row[LocResolutions.COUNTRY]; + delete row[LocResolutions.COUNTRY]; + } else if (row[LocResolutions.SUBREGION]) { + row[Dimensions.LOCATION] = row[LocResolutions.SUBREGION]; + delete row[LocResolutions.SUBREGION]; + } + return row; + }); }; const doLoadData = debounce(async () => { @@ -91,5 +79,5 @@ export const useDataStore = defineStore("data", () => { } }, { immediate: true }); - return { histogramData, fetchErrors, geographicalResolutions }; + return { histogramData, fetchErrors }; }); diff --git a/src/types.ts b/src/types.ts index cfd3740..2963d22 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,12 +16,27 @@ export enum LocResolutions { COUNTRY = "country", } -export type DataRow = Record; +export enum HistCols { + LOWER_BOUND = "lower_bound", + UPPER_BOUND = "upper_bound", + COUNTS = "Counts", +} + +type DataRow = Record; export type SummaryTableDataRow = DataRow & { [Dimensions.DISEASE]: string; [LocResolutions.COUNTRY]?: string; [LocResolutions.SUBREGION]?: string; }; +export type HistDataRow = DataRow & { + [Dimensions.DISEASE]: string; + [LocResolutions.COUNTRY]?: string; + [LocResolutions.SUBREGION]?: string; + [Dimensions.LOCATION]?: string; + [HistCols.LOWER_BOUND]: number; + [HistCols.UPPER_BOUND]: number; + [HistCols.COUNTS]: number; +}; export type Option = { label: string; value: string }; From dfc64281b7a17a677913c0eaf2d06f8f1263a1f4 Mon Sep 17 00:00:00 2001 From: David Mears Date: Thu, 11 Dec 2025 16:24:27 +0000 Subject: [PATCH 06/20] Refactor filtes to be computed --- src/stores/appStore.ts | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 221a8b1..f563e7e 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -35,11 +35,6 @@ export const useAppStore = defineStore("app", () => { const exploreBy = ref(Dimensions.LOCATION); const focus = ref(LocResolutions.GLOBAL); - const filters = ref({ - [Dimensions.LOCATION]: [] as string[], - [Dimensions.DISEASE]: [] as string[], - }); - const exploreByLabel = computed(() => { const option = exploreOptions.find(o => o.value === exploreBy.value); return option ? option.label : ""; @@ -70,6 +65,22 @@ export const useAppStore = defineStore("app", () => { } }); + const focusIsADisease = computed(() => diseaseOptions.map(o => o.value).includes(focus.value)); + + const filters = computed(() => { + if (focusIsADisease.value) { + return { + [Dimensions.DISEASE]: [focus.value], + [Dimensions.LOCATION]: subregionOptions.map(o => o.value).concat([LocResolutions.GLOBAL]), + }; + } else { + return { + [Dimensions.DISEASE]: diseaseOptions.map(d => d.value), + [Dimensions.LOCATION]: geographicalResolutions.value.map(getLocationForGeographicalResolution), + }; + } + }) + const getLocationForGeographicalResolution = (geog: LocResolutions) => { switch (geog) { case LocResolutions.GLOBAL: @@ -90,19 +101,11 @@ export const useAppStore = defineStore("app", () => { }; }); - // TODO: watch focusIsADisease instead? Then filters could be computed from that + focus? - watch(focus, () => { - const focusIsADisease = diseaseOptions.find(d => d.value === focus.value); - if (focusIsADisease) { - filters.value[Dimensions.DISEASE] = [focus.value]; - filters.value[Dimensions.LOCATION] = subregionOptions.map(o => o.value).concat([LocResolutions.GLOBAL]); - + watch(focusIsADisease, () => { + if (focusIsADisease.value) { yCategoricalAxis.value = Dimensions.LOCATION; withinBandAxis.value = Dimensions.DISEASE; } else { - filters.value[Dimensions.DISEASE] = diseaseOptions.map(d => d.value); - filters.value[Dimensions.LOCATION] = geographicalResolutions.value.map(getLocationForGeographicalResolution); - // This is only one possible way of 'focusing' on a 'location': // diseases as categorical Y axis, each row with up to 3 ridges. // An alternative would be to have the 3 location rows laid out on the categorical Y axis, From 08ff003ab3021f272d40af0f829102b1128c29dd Mon Sep 17 00:00:00 2001 From: David Mears Date: Fri, 12 Dec 2025 10:12:23 +0000 Subject: [PATCH 07/20] Do line filtration at the point of creating line objects, not after --- src/components/RidgelinePlot.vue | 150 +++++++++++++++---------------- src/stores/appStore.ts | 35 ++++---- src/types.ts | 1 + 3 files changed, 89 insertions(+), 97 deletions(-) diff --git a/src/components/RidgelinePlot.vue b/src/components/RidgelinePlot.vue index d82c132..1dbefc2 100644 --- a/src/components/RidgelinePlot.vue +++ b/src/components/RidgelinePlot.vue @@ -5,7 +5,7 @@ id="chartWrapper" :data-test="JSON.stringify({ histogramDataRowCount: dataStore.histogramData.length, - lineCount: filteredLines.length, + lineCount: ridgeLines.length, x: appStore.dimensions.x, y: appStore.dimensions.y, withinBand: appStore.dimensions.withinBand, @@ -13,8 +13,8 @@ />

Errors: {{ dataStore.fetchErrors }}

    -
  • - Line {{ index + 1 }} - Bands: {{ line.bands }}, within band: {{ line.metadata?.withinBandAxisValue }} +
  • + Line {{ index + 1 }} - Bands: {{ line.bands }}, within band: {{ line.metadata?.withinBandVal }}
@@ -22,12 +22,13 @@ diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 6c786cf..b538541 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -1,7 +1,7 @@ import { defineStore } from "pinia"; import { computed, ref, watch } from "vue"; import { getSubregionFromCountry } from "@/utils/regions" -import { BurdenMetrics, Dimensions, LocResolutions } from "@/types"; +import { Axes, BurdenMetrics, Dimensions, LocResolutions } from "@/types"; import countryOptions from '@/data/options/countryOptions.json'; import subregionOptions from '@/data/options/subregionOptions.json'; import diseaseOptions from '@/data/options/diseaseOptions.json'; @@ -36,12 +36,12 @@ export const useAppStore = defineStore("app", () => { return option ? option.label : ""; }); - // The dimensions currently in use: up to three will be in use at any given time. + // The dimensions currently in use, by axis: up to three will be in use at any given time. const dimensions = computed(() => ({ - x: xCategoricalAxis.value, - y: yCategoricalAxis.value, - withinBand: withinBandAxis.value - })); + [Axes.X]: xCategoricalAxis.value, + [Axes.Y]: yCategoricalAxis.value, + [Axes.WITHIN_BAND]: withinBandAxis.value + } as Record)); // The geographical resolutions to use based on current exploreBy and focus selections. const geographicalResolutions = computed(() => { diff --git a/src/stores/colorStore.ts b/src/stores/colorStore.ts index e8f6738..f00f7f4 100644 --- a/src/stores/colorStore.ts +++ b/src/stores/colorStore.ts @@ -1,8 +1,7 @@ import { defineStore } from "pinia"; import { computed, ref } from "vue"; -import { Dimensions, LocResolutions, type LineMetadata } from "@/types"; +import { Axes, Dimensions, type LineMetadata } from "@/types"; import { useAppStore } from "@/stores/appStore"; -import titleCase from "@/utils/titleCase"; import { globalOption } from "@/utils/options"; // The IBM categorical palette, which maximises accessibility, specifically in this ordering: @@ -24,6 +23,8 @@ const colors = [ "#a56eff", ]; +type ColorMapping = Record>; + export const useColorStore = defineStore("color", () => { const appStore = useAppStore(); @@ -31,12 +32,7 @@ export const useColorStore = defineStore("color", () => { // use the same color assignations consistently across rows. // The two nested maps map specific values to assigned colors. // In the future this will be passed as a prop to a legend component. - const colorsByValue = ref>>( - Object.freeze({ - [Dimensions.LOCATION]: new Map(), - [Dimensions.DISEASE]: new Map(), - }), - ); + const colorsByValue = ref(); // colorDimension is the dimension (i.e. 'location' or 'disease') // whose values determine the colors for the lines. @@ -46,33 +42,40 @@ export const useColorStore = defineStore("color", () => { // If we're filtered to just 1 value for the withinBand axis, // we assign colors based on the dimension assigned to the y-axis, // otherwise all lines would be the same color across all rows. - return appStore.filters[appStore.dimensions.withinBand]?.length === 1 - ? appStore.dimensions.y - : appStore.dimensions.withinBand; + return appStore.filters[appStore.dimensions[Axes.WITHIN_BAND]]?.length === 1 + ? appStore.dimensions[Axes.Y] + : appStore.dimensions[Axes.WITHIN_BAND]; }); - const colorMap = computed(() => colorsByValue.value[colorDimension.value]!); + const colorMapping = computed(() => colorsByValue.value?.[colorDimension.value]); - const getColorForLine = (categories: LineMetadata) => { + const getColorForLine = (categoryValues: LineMetadata) => { + const colorAxis = Object.keys(appStore.dimensions).find((axis) => { + return appStore.dimensions[axis as Axes] === colorDimension.value; + }) as Axes; + const colorMap = colorMapping.value; + if (!colorMap) { + return undefined; + } // `value` is the specific value, i.e. a specific location or disease, // whose color we need to look up or assign. - const value = colorDimension.value === appStore.dimensions.y - ? categories.yVal - : categories.withinBandVal; - const color = colorMap.value.get(value) ?? colors[colorMap.value.size % colors.length]!; - colorMap.value.set(value, color); - return colorMap.value.get(value); + const value = categoryValues[colorAxis]; + let color = colorMap.get(value); + if (!color) { + color = colors[colorMap.size % colors.length]!; + colorMap.set(value, color); + } + return color; } - // TODO: Find a good way to ensure that 'global' gets the same color across chart updates. const resetColorMapping = () => { - const globalColor = colorMap.value.get(globalOption.value) ?? colors[0]!; - + // By setting the global color once here, we ensure that it gets the same color across chart updates. + // NB `Object.freeze` is only a shallow freeze, preventing modification of the top-level object structure. colorsByValue.value = Object.freeze({ - [Dimensions.LOCATION]: new Map([[globalOption.value, globalColor]]), + [Dimensions.LOCATION]: new Map([[globalOption.value, colors[0]!]]), [Dimensions.DISEASE]: new Map(), }); - } + }; - return { colorDimension, colorMap, getColorForLine, resetColorMapping }; + return { colorDimension, colorMap: colorMapping, getColorForLine, resetColorMapping }; }); diff --git a/src/types.ts b/src/types.ts index 6a5c700..75043ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,8 +42,13 @@ export type Option = { label: string; value: string }; export type Coords = { x: number; y: number }; -export type LineMetadata = { - withinBandVal: string; - xVal: string; - yVal: string -}; +// The x categorical axis corresponds to horizontal slicing of the ridgeline plot (columns). +// The y categorical axis corresponds to the rows of the ridgeline plot. +// The 'within-band' axis is often denoted by color. It distinguishes different lines that share the same categorical axis values. +export enum Axes { + X = "x", + Y = "y", + WITHIN_BAND = "withinBand", +} + +export type LineMetadata = Record; diff --git a/src/utils/fileParse.ts b/src/utils/fileParse.ts index 9572030..fa6f3d3 100644 --- a/src/utils/fileParse.ts +++ b/src/utils/fileParse.ts @@ -14,13 +14,3 @@ export const getDimensionCategoryValue = (dim: Dimensions | null, dataRow: HistD return value; } }; - -// TODO: Make this function more generic, like, actually-always look up the label. -// TODO: Move to a more relevant file -// Get an data category's human-readable label from its value & dimension. -export const getCategoryLabel = (dim: Dimensions, value: string): string => { - if (dim === Dimensions.LOCATION && value === globalOption.value) { - return globalOption.label; - } - return value; -}; diff --git a/src/utils/options.ts b/src/utils/options.ts index c91a2fe..48196e2 100644 --- a/src/utils/options.ts +++ b/src/utils/options.ts @@ -2,6 +2,9 @@ // Static, automatically-generated options go in src/data/options/. import { BurdenMetrics, Dimensions, LocResolutions } from "@/types"; import countryOptions from '@/data/options/countryOptions.json'; +import diseaseOptions from '@/data/options/diseaseOptions.json'; +import subregionOptions from '@/data/options/subregionOptions.json'; +import activityTypeOptions from '@/data/options/activityTypeOptions.json'; export const metricOptions = [ { label: "DALYs averted", value: BurdenMetrics.DALYS }, @@ -17,3 +20,16 @@ export const globalOption = { label: `All ${countryOptions.length} VIMC countries`, value: LocResolutions.GLOBAL as string }; + +const locationOptions = countryOptions.concat(subregionOptions).concat([globalOption]); + +// Get an data category's human-readable label from its value & dimension. +export const dimensionOptionLabel = (dim: Dimensions, value: string): string => { + const options = { + [Dimensions.LOCATION]: locationOptions, + [Dimensions.DISEASE]: diseaseOptions, + [Dimensions.ACTIVITY_TYPE]: activityTypeOptions, + }[dim]; + + return options?.find(o => o.value === value)?.label ?? value; +}; From 7d84d8f0170d7685ebae03827019cc4cd6e8f8a4 Mon Sep 17 00:00:00 2001 From: David Mears Date: Mon, 15 Dec 2025 16:44:36 +0000 Subject: [PATCH 16/20] Update skadi-chart version and tell user if no data available --- package-lock.json | 8 ++++---- package.json | 2 +- src/components/RidgelinePlot.vue | 16 ++++++++++------ 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index cfaad34..d33937c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "vaxviz", "version": "0.0.0", "dependencies": { - "@reside-ic/skadi-chart": "^1.1.2", + "@reside-ic/skadi-chart": "^1.1.3", "flowbite": "^4.0.1", "flowbite-vue": "^0.2.2", "perfect-debounce": "^2.0.0", @@ -2135,9 +2135,9 @@ } }, "node_modules/@reside-ic/skadi-chart": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@reside-ic/skadi-chart/-/skadi-chart-1.1.2.tgz", - "integrity": "sha512-c8ddw4XeCy9xZqSMZDOwApc4HUy/966hU/hbgWvjpNndg3h0DMKLuXQexYRpSl2OeFN9zBxCQtyab1H28BqJzA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@reside-ic/skadi-chart/-/skadi-chart-1.1.3.tgz", + "integrity": "sha512-CRv1X4LqMwe1+EYnkqSzl5w2HZsyxiusjGbZDQtBjId678QuYlC+WbCFfjE8mPMj6TQYUlWRW52bgYPpCNzUWA==", "dependencies": { "d3-axis": "^3.0.0", "d3-brush": "^3.0.0", diff --git a/package.json b/package.json index 741c1d4..ad68f4b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "format": "prettier --write src/" }, "dependencies": { - "@reside-ic/skadi-chart": "^1.1.2", + "@reside-ic/skadi-chart": "^1.1.3", "flowbite": "^4.0.1", "flowbite-vue": "^0.2.2", "perfect-debounce": "^2.0.0", diff --git a/src/components/RidgelinePlot.vue b/src/components/RidgelinePlot.vue index 28de192..329dd56 100644 --- a/src/components/RidgelinePlot.vue +++ b/src/components/RidgelinePlot.vue @@ -1,8 +1,13 @@ diff --git a/tests/unit/components/RidgelinePlot.spec.ts b/tests/unit/components/RidgelinePlot.spec.ts index a74eafb..c2c8e2b 100644 --- a/tests/unit/components/RidgelinePlot.spec.ts +++ b/tests/unit/components/RidgelinePlot.spec.ts @@ -13,6 +13,7 @@ import histCountsDalysDiseaseLog from "@/../public/data/json/hist_counts_dalys_d import { BurdenMetrics } from '@/types'; import RidgelinePlot from '@/components/RidgelinePlot.vue' import { useAppStore } from "@/stores/appStore"; +import { useColorStore } from '@/stores/colorStore'; vi.mock('@reside-ic/skadi-chart', () => ({ Chart: vi.fn().mockImplementation(class MockChart { @@ -33,6 +34,7 @@ describe('RidgelinePlot component', () => { it('loads the correct data', async () => { const appStore = useAppStore(); + const colorStore = useColorStore(); const wrapper = mount(RidgelinePlot) await vi.waitFor(() => { @@ -47,6 +49,8 @@ describe('RidgelinePlot component', () => { // (except that in this case there is only one location in use at the moment, 'global') expect(dataAttr.withinBand).toEqual("location"); }); + // Color by row; each disease has been assigned a color. + expect(colorStore.colorMapping.size).toEqual(14); // Change options: round 1 expect(appStore.exploreBy).toEqual("location"); @@ -65,6 +69,8 @@ describe('RidgelinePlot component', () => { expect(dataAttr.y).toEqual("disease"); expect(dataAttr.withinBand).toEqual("location"); }); + // Color by the 2 locations within each band: Middle Africa and global. + expect(colorStore.colorMapping.size).toEqual(2); // Change options: round 2 appStore.exploreBy = "disease"; @@ -85,6 +91,8 @@ describe('RidgelinePlot component', () => { expect(dataAttr.y).toEqual("location"); expect(dataAttr.withinBand).toEqual("disease"); }); + // Color by row; each location (10 subregions + global) has been assigned a color. + expect(colorStore.colorMapping.size).toEqual(11); // Change options: round 3 appStore.exploreBy = "location"; @@ -105,5 +113,7 @@ describe('RidgelinePlot component', () => { expect(dataAttr.y).toEqual("disease"); expect(dataAttr.withinBand).toEqual("location"); }, { timeout: 2500 }); + // Color by the 3 locations within each band: AFG, Central and Southern Asia, and global. + expect(colorStore.colorMapping.size).toEqual(3); }); });