Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a22f84b
Add a summaryTablePaths getter to data store
david-mears-2 Jan 15, 2026
9d81a89
DRY the filename constructor methods and fix naming from 'path' => 'f…
david-mears-2 Jan 15, 2026
2a5b96f
Rename existing internal methods on data store
david-mears-2 Jan 15, 2026
596626c
Update data store to load data from summary tables
david-mears-2 Jan 15, 2026
7d19845
DRY the data store
david-mears-2 Jan 15, 2026
9d8bc1e
Merge branch 'main' into populate-tooltips-with-summary-data
david-mears-2 Jan 16, 2026
6413e19
Merge branch 'update-skadi-chart-to-1.1.7-alpha.0' into populate-tool…
david-mears-2 Jan 16, 2026
2a310b9
Merge branch 'rename-enums' into populate-tooltips-with-summary-data
david-mears-2 Jan 16, 2026
330795c
Merge branch 'rename-enums' into populate-tooltips-with-summary-data
david-mears-2 Jan 16, 2026
e8afa2d
Bugfix for data store
david-mears-2 Jan 16, 2026
84a7c3f
Populate tooltips with summary table data
david-mears-2 Jan 16, 2026
2682e0c
Fix unit test failures
david-mears-2 Jan 16, 2026
6d4c022
Initial plan
Copilot Jan 16, 2026
5cfc536
Initial exploration complete
Copilot Jan 16, 2026
a654ed6
Add tests for new tooltip content (median, mean, 95% CI)
Copilot Jan 16, 2026
7b8abd3
Update tests to verify row dimension display and summary data in tool…
Copilot Jan 16, 2026
05db003
Add assertions to verify row dimension not shown when same as color d…
Copilot Jan 19, 2026
2b95c6a
Update unit tests
david-mears-2 Jan 19, 2026
c72cf02
Merge pull request #32 from vimc/copilot/update-use-plot-tooltips-tests
david-mears-2 Jan 19, 2026
4f06a32
Comment
david-mears-2 Jan 19, 2026
be04d52
Merge branch 'rename-enums' into populate-tooltips-with-summary-data
david-mears-2 Jan 19, 2026
1a65432
Fix refernce to array that should check length not existence of (empt…
david-mears-2 Jan 19, 2026
2011864
Implement review comments
david-mears-2 Jan 21, 2026
f8272ab
Use scientific notation in tooltips when in log scale
david-mears-2 Jan 21, 2026
9f3e4fb
Fix type
david-mears-2 Jan 21, 2026
cc1061a
Update comment
david-mears-2 Jan 21, 2026
4cbd8e1
Extract a function in tooltips composable
david-mears-2 Jan 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/generateOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ let activityTypeOpts: Option[] = [];
if (activityTypeValue && !activityTypeOpts.find(o => o.value === activityTypeValue)) {
activityTypeOpts.push({
value: activityTypeValue,
label: titleCase(activityTypeValue)
label: titleCase(activityTypeValue)!
});
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/assets/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@
border: 1px solid black;
border-radius: 0.25em;
padding: 0.5em;
opacity: 0.8;
opacity: 0.9;
position: relative;
top: 40px;
right: 100px;
width: max-content;
}
80 changes: 66 additions & 14 deletions src/composables/usePlotTooltips.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,91 @@
import { useAppStore } from "@/stores/appStore";
import { useColorStore } from "@/stores/colorStore";
import { Axis, type PointWithMetadata } from "@/types";
import { useDataStore } from "@/stores/dataStore";
import { Axis, SummaryTableColumn, type LineMetadata, type PointWithMetadata } from "@/types";
import { dimensionOptionLabel } from "@/utils/options";
import sentenceCase from "@/utils/sentenceCase";

export default () => {
const colorStore = useColorStore();
const appStore = useAppStore();
const dataStore = useDataStore();

const convertToScientificNotation = (num: number): string => {
if (!appStore.logScaleEnabled) {
return num.toFixed(2);
}
if (num === 0) return "0";
const exponent = Math.floor(Math.log10(Math.abs(num)));
const coefficient = (num / Math.pow(10, exponent)).toFixed(2);
return `${coefficient} × 10<sup>${exponent}</sup>`;
};

// Find the summary table row whose values for the plot row and band
// dimensions (and column, if set) match the values of the tooltip point metadata
const getSummaryDataRow = (metadata: LineMetadata) => {
return dataStore.summaryTableData.find(d => {
return Object.entries(appStore.dimensions).every(([axis, dim]) => {
return !dim || d[dim] === metadata?.[axis as Axis];
});
});
};

// Generate HTML for tooltips on ridgeline plot points.
// This callback is passed to skadi-chart, and is invoked when hovering over the chart.
const tooltipCallback = (point: PointWithMetadata) => {
if (!point.metadata) return "";

const { strokeColor } = colorStore.getColorsForLine(point.metadata)
const { colorDimension } = colorStore;
const { colorAxis, colorDimension } = colorStore;
const { dimensions } = appStore;

const rowOptionLabel = dimensionOptionLabel(colorDimension, point.metadata[colorStore.colorAxis]);
const valueForColorDimension = point.metadata[colorAxis];

const colorDimensionLabel = dimensionOptionLabel(colorDimension, valueForColorDimension);
const rowOptionLabel = dimensionOptionLabel(dimensions.row, point.metadata[Axis.ROW]);
const columnOptionLabel = dimensionOptionLabel(dimensions.column, point.metadata[Axis.COLUMN]);

const summaryDataRow = getSummaryDataRow(point.metadata);

// NB regardless of whether log scale is enabled, the summary table data are provided in non-log scale.
const [mean, ciLower, ciUpper] = [
SummaryTableColumn.MEAN,
SummaryTableColumn.CI_LOWER,
SummaryTableColumn.CI_UPPER,
].map(col => summaryDataRow?.[col]);

let ciLowerStr;
let ciUpperStr;
if (appStore.logScaleEnabled) {
ciUpperStr = convertToScientificNotation(ciUpper!);
ciLowerStr = convertToScientificNotation(ciLower!);
} else {
const includePositiveSign = ciLower! < 0;
const ciUpperSign = (includePositiveSign && ciUpper! > 0) ? '+' : '';
ciUpperStr = `${ciUpperSign}${ciUpper?.toFixed(2)}`;
ciLowerStr = ciLower?.toFixed(2);
}

const meanStr = appStore.logScaleEnabled ? convertToScientificNotation(mean!) : mean?.toFixed(2)

return `<div class="tooltip text-xs flex flex-col gap-1 w-75">
<div class="flex gap-1 items-center">
<span style="color: ${strokeColor}; font-size: 1.3rem;">●</span>
<span class="mt-1 flex flex-wrap gap-5">
<span>
${sentenceCase(colorDimension)}: <strong>${rowOptionLabel}</strong>
</span>
${dimensions.column ? `<span>
${sentenceCase(dimensions.column)}: <strong>${columnOptionLabel}</strong>
</span>` : ''}
<div class="flex gap-1 h-6 items-center">
<span style="color: ${strokeColor}; font-size: 1.5rem;">●</span>
<span class="mt-1">
${sentenceCase(colorDimension)}: <b>${colorDimensionLabel}</b>
</span>
</div>
<p>Tooltip content is TODO. VIMC-9196</p>
${dimensions.row !== colorDimension ? `<span>
${sentenceCase(dimensions.row)}: <b>${rowOptionLabel}</b>
</span>` : ''}
${dimensions.column && dimensions.column !== colorDimension ? `<span>
${sentenceCase(dimensions.column)}: <b>${columnOptionLabel}</b>
</span>` : ''}
<p class="mt-1">
Mean: <b>${meanStr}</b><br/>
</p>
<p>
Will show the median/mean values and 95% confidence interval for the whole line.
95% confidence interval: <b>${ciLowerStr}</b> — <b>${ciUpperStr}</b>
</p>
</div>`
}
Expand Down
92 changes: 59 additions & 33 deletions src/stores/dataStore.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { debounce } from "perfect-debounce";
import { computed, ref, shallowRef, watch } from "vue";
import { computed, ref, shallowRef, watch, type ShallowRef } from "vue";
import { defineStore } from "pinia";
import { useAppStore } from "@/stores/appStore";
import { type HistDataRow, Dimension, LocResolution } from "@/types";
import { type HistDataRow, type SummaryTableDataRow, Dimension, LocResolution } from "@/types";
import { globalOption } from "@/utils/options";

export const dataDir = `./data/json`

Expand All @@ -11,13 +12,14 @@ export const useDataStore = defineStore("data", () => {

const fetchErrors = ref<{ e: Error, message: string }[]>([]);
const histogramData = shallowRef<HistDataRow[]>([]);
const histogramDataCache: Record<string, HistDataRow[]> = {};
const histogramCache: Record<string, HistDataRow[]> = {};
const summaryTableData = shallowRef<SummaryTableDataRow[]>([]);
const summaryTableCache: Record<string, SummaryTableDataRow[]> = {};
const isLoading = ref(true);

const histogramDataPaths = computed(() => {
// When we are using multiple geographical resolutions, we need to load multiple data files, to be merged together later.
const constructFilenames = (dataType: "hist_counts" | "summary_table"): string[] => {
return appStore.geographicalResolutions.map((geog) => {
const fileNameParts = ["hist_counts", appStore.burdenMetric, "disease"];
const fileNameParts = [dataType, appStore.burdenMetric, "disease"];
// NB files containing 'global' data simply omit location from the file name (as they have no location stratification).
if (geog === LocResolution.SUBREGION) {
fileNameParts.push(LocResolution.SUBREGION);
Expand All @@ -28,59 +30,83 @@ export const useDataStore = defineStore("data", () => {
if (geog === LocResolution.COUNTRY) {
fileNameParts.push(LocResolution.COUNTRY);
}
if (appStore.logScaleEnabled) {
if (dataType === "hist_counts" && appStore.logScaleEnabled) {
// Log scale is not applicable for summary tables, so does not appear in the filenames.
fileNameParts.push("log");
}
return `${fileNameParts.join("_")}.json`
return `${fileNameParts.join("_")}.json`;
});
});
}

// Fetch and parse multiple JSONs, and merge together all data.
const loadDataFromPaths = async (paths: string[]) => {
isLoading.value = true;
fetchErrors.value = [];
await Promise.all(paths.map(async (path) => {
if (!histogramDataCache[path]) {
const histFilenames = computed(() => constructFilenames("hist_counts"));
const summaryTableFilenames = computed(() => constructFilenames("summary_table"));

const loadData = async <T extends HistDataRow | SummaryTableDataRow>(
filenames: string[],
cache: Record<string, T[]>,
ref: ShallowRef<T[]>,
) => {
// When we are using multiple geographical resolutions, we load multiple data files, to be merged together later.
await Promise.all(filenames.map(async (filename) => {
if (!cache[filename]) {
const path = `${dataDir}/${filename}`;
try {
const response = await fetch(`${dataDir}/${path}`);
const response = await fetch(path);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const rows = await response.json();
histogramDataCache[path] = rows;
cache[filename] = rows;
return rows;
} catch (error) {
fetchErrors.value.push({ e: error as Error, message: `Error loading data from path: ${path}. ${error}` });
}
}
}));

histogramData.value = paths.flatMap((path) => histogramDataCache[path] || []).map((row) => {
// Merge data fetched from multiple files into one array.
ref.value = filenames.flatMap((filename) => {
return (cache[filename] || []) as T[];
}).map((row) => {
// Collapse all geographic columns into one 'location' column
if (row[LocResolution.COUNTRY]) {
row[Dimension.LOCATION] = row[LocResolution.COUNTRY];
delete row[LocResolution.COUNTRY];
} else if (row[LocResolution.SUBREGION]) {
row[Dimension.LOCATION] = row[LocResolution.SUBREGION];
delete row[LocResolution.SUBREGION];
const [country, subregion] = [row[LocResolution.COUNTRY], row[LocResolution.SUBREGION]];
const newRow = { ...row };
if (country) {
newRow[Dimension.LOCATION] = country;
delete newRow[LocResolution.COUNTRY];
} else if (subregion) {
newRow[Dimension.LOCATION] = subregion;
delete newRow[LocResolution.SUBREGION];
} else {
newRow[Dimension.LOCATION] = globalOption.value;
}
return row;
return newRow;
});
isLoading.value = false;
};

const doLoadData = debounce(async () => {
await loadDataFromPaths(histogramDataPaths.value);
const loadAllData = async () => {
isLoading.value = true;
fetchErrors.value = [];
await Promise.all([
loadData<HistDataRow>(histFilenames.value, histogramCache, histogramData),
loadData<SummaryTableDataRow>(summaryTableFilenames.value, summaryTableCache, summaryTableData),
]);
isLoading.value = false;
};

const debouncedLoadAllData = debounce(async () => {
await loadAllData();
}, 25)

watch(histogramDataPaths, async (_oldPaths, newPaths) => {
if (newPaths) {
doLoadData();
watch([histFilenames, summaryTableFilenames], async (_oldPaths, newPaths) => {
if (newPaths.length) {
debouncedLoadAllData();
} else {
// This is the first time histDataPaths is calculated, so don't debounce.
await loadDataFromPaths(histogramDataPaths.value);
// This is the first time the filenames are computed, so don't debounce.
await loadAllData();
}
}, { immediate: true });

return { histogramData, fetchErrors, isLoading };
return { fetchErrors, isLoading, histogramData, summaryTableData };
});
27 changes: 20 additions & 7 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,34 @@ export enum HistColumn {
COUNTS = "Counts",
}

type DataRow = Record<string, string | number>;
export type SummaryTableDataRow = DataRow & {
[Dimension.DISEASE]: string;
[LocResolution.COUNTRY]?: string;
[LocResolution.SUBREGION]?: string;
};
export type HistDataRow = DataRow & {
export enum SummaryTableColumn {
MEAN = "mean_value",
MEDIAN = "median_value",
CI_LOWER = "lower_95",
CI_UPPER = "upper_95",
}

export type HistDataRow = {
[Dimension.DISEASE]: string;
[Dimension.ACTIVITY_TYPE]?: string;
[LocResolution.COUNTRY]?: string;
[LocResolution.SUBREGION]?: string;
[Dimension.LOCATION]?: string;
[HistColumn.LOWER_BOUND]: number;
[HistColumn.UPPER_BOUND]: number;
[HistColumn.COUNTS]: number;
};
export type SummaryTableDataRow = {
[Dimension.DISEASE]: string;
[Dimension.ACTIVITY_TYPE]?: string;
[LocResolution.COUNTRY]?: string;
[LocResolution.SUBREGION]?: string;
[Dimension.LOCATION]?: string;
[SummaryTableColumn.MEAN]: number;
[SummaryTableColumn.MEDIAN]: number;
[SummaryTableColumn.CI_LOWER]: number;
[SummaryTableColumn.CI_UPPER]: number;
}

export type Option = { label: string; value: string };

Expand Down
2 changes: 1 addition & 1 deletion src/utils/fileParse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { globalOption } from "./options";

// Get a data row's category for some categorical axis.
export const getDimensionCategoryValue = (dim: Dimension | null, dataRow: HistDataRow): string => {
const value = dataRow[dim ?? ""] as string;
const value = dataRow[dim as keyof HistDataRow] as string;
if (dim === Dimension.LOCATION && !value) {
// A missing column for the location dimension implies 'global' category.
return globalOption.value;
Expand Down
Loading