Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "vaxviz",
"version": "0.0.0",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
Expand Down
42 changes: 30 additions & 12 deletions src/App.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,38 @@
<template>
<AppHeader />
<main class="flex">
<PlotControls />
<div class="flex flex-col w-full h-full max-w-full max-h-full gap-y-5">
<RidgelinePlot />
<ColorLegend />
</div>
</main>
</template>

<script setup lang="ts">
import AppHeader from './components/AppHeader.vue';
import PlotControls from './components/PlotControls.vue';
import ColorLegend from './components/ColorLegend.vue';
import RidgelinePlot from './components/RidgelinePlot.vue';
</script>

<template>
<div class="flex h-screen max-h-screen">
<div class="flex h-full max-h-full flex-col justify-start gap-y-5">
<PlotControls />
<div class="w-75 m-5">
<ColorLegend />
</div>
</div>
<RidgelinePlot />
</div>
</template>
<style lang="scss">
$header-padding: calc(var(--spacing)* 5);
$logo-height: calc(var(--spacing)* 20);

header {
padding: $header-padding;
margin-bottom: $header-padding;

img#logo {
height: $logo-height;
}
}

main {
$header-height: calc(#{$logo-height} + (#{$header-padding} * 3));

<style scoped>
height: calc(100vh - #{$header-height});
max-height: calc(100vh - #{$header-height});
}
</style>
9 changes: 9 additions & 0 deletions src/assets/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,12 @@
right: 100px;
width: max-content;
}

a, button.link {
color: var(--color-fg-brand);
text-decoration: underline;
}

button {
cursor: pointer;
}
82 changes: 82 additions & 0 deletions src/components/AppHeader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<template>
<header class="border-b border-gray-300 flex items-center justify-between">
<div class="flex flex-col gap-2">
<a href="https://www.vaccineimpact.org/" target="_blank">
<img src="/logo.png" id="logo" alt="VIMC logo" />
</a>
</div>
<FwbAlert
type="danger"
class="border-t-4 rounded-none"
>
<template #icon>
<img class="w-4 h-4 mr-2" src="@/assets/images/icons/dangerInfoIcon.svg" alt=""/>
<span class="sr-only">Error</span>
</template>
<template #title>
<h3 class="text-lg font-medium">
Provisional estimates. Not to be forwarded or cited.
</h3>
</template>
<template #default>
<p class="mt-2">This is a preview. All estimates shown are representative only. Do not use or forward them.</p>
</template>
</FwbAlert>
<div class="flex flex-col gap-4 items-end">
<!-- TODO: When paper is published, add the link, and remove 'forthcoming'. -->
<p class="text-right">This visualization tool accompanies Gaythorpe et al. (forthcoming)</p>
<button
id="aboutLink"
@click="aboutModalVisible = true"
href="#"
class="link"
>
About
</button>
</div>
</header>
<FwbModal
v-if="aboutModalVisible"
@close="aboutModalVisible = false"
>
<template #header>
<div class="text-lg">
About
</div>
</template>
<template #body>
<div class="flex flex-col gap-y-4 leading-relaxed">
<!-- TODO: When paper is published, add the link and replace '(forthcoming)' with '(2026)'. -->
<p>
This visualization tool accompanies VIMC's fourth publication, Gaythorpe et al (forthcoming).
</p>
<!-- TODO: The commented text will be uncommented once the estimates are final / published; until then we have to caveat them. -->
<!-- <p>
It shows VIMC's estimates of health impact from vaccination against 14 diseases in {{ countryOptions.length }} low- and middle-income countries from 2000 to 2030
(2040 for cholera) for the <a href="https://www.gavi.org/" target="_blank">Gavi</a> portfolio of vaccination programmes.
</p> -->
<!-- NB: The number of diseases is 14 per the paper, and not (necessarily) the length of diseaseOptions.json, which may carve up diseases differently (particularly Meningitis). -->
<p>
Once that paper is published, this will show VIMC's estimates of health impact from vaccination against 14 diseases in {{ countryOptions.length }} low- and middle-income countries from 2000 to 2030
(2040 for cholera) for the <a href="https://www.gavi.org/" target="_blank">Gavi</a> portfolio of vaccination programmes. The numbers shown are only representative, pending publication.
</p>
<p>
Model estimates are presented in terms of 'vaccine impact ratios', defined as deaths or disability-adjusted life years (DALYs) averted per vaccination.
</p>
<p class="text-xs text-gray-500 mt-2">
Vaxviz version: {{ version }}
</p>
</div>
</template>
</FwbModal>
</template>

<script setup lang="ts">
import { FwbAlert, FwbModal } from 'flowbite-vue';
import countryOptions from '@/data/options/countryOptions.json';
import { ref } from 'vue';

import { version } from '@/../package.json';

const aboutModalVisible = ref(false);
</script>
16 changes: 10 additions & 6 deletions src/components/ColorLegend.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
<template>
<div
v-if="colors && colors.length >= 2"
class="max-w-full h-full"
class="h-20 flex mb-5 w-fit ml-10"
id="colorLegend"
>
<h3 class="fs-3 font-medium mb-5 text-heading mb-2">
Legend
</h3>
<ul class="flex flex-col gap-y-1 max-h-full max-w-full">
<ul
class="flex flex-col gap-y-1 flex-wrap max-h-full min-h-0"
:style="{ 'margin-left': `${plotLeftMargin}px` }"
>
<li
v-for="([value, color]) in colors"
:key="value"
class="flex gap-x-2 text-sm"
class="flex gap-x-2 text-sm mr-20"
>
<span
class="legend-color-box"
Expand All @@ -32,6 +32,7 @@ import { useColorStore } from '@/stores/colorStore';
import { dimensionOptionLabel } from '@/utils/options';
import { useAppStore } from '@/stores/appStore';
import { Axis, Dimension, LocResolution } from '@/types';
import { margins } from '@/utils/plotConfiguration';

const appStore = useAppStore();
const colorStore = useColorStore();
Expand Down Expand Up @@ -65,6 +66,9 @@ const colorBoxStyle = (color: HEX) => {
borderColor: strokeColor,
}
}

// Align the left edge of the legend with that of the plot.
const plotLeftMargin = computed(() => margins(appStore.dimensions[Axis.ROW]).left);
</script>

<style scoped>
Expand Down
19 changes: 2 additions & 17 deletions src/components/RidgelinePlot.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div class="chart-container">
<div class="chart-container min-h-0 flex-1 flex">
<FwbSpinner v-if="dataStore.isLoading" class="m-auto" size="8" />
<FetchErrorAlert v-else-if="dataStore.fetchErrors.length" />
<p v-else-if="noDataToDisplay" class="m-auto">
Expand All @@ -15,6 +15,7 @@
lineCount: relevantRidgeLines.length,
...appStore.dimensions,
})"
class="flex-1 m-10"
/>
</div>
</template>
Expand Down Expand Up @@ -139,19 +140,3 @@ const updateChart = debounce(() => {
watch([relevantRidgeLines, () => appStore.focus, chartWrapper], updateChart, { immediate: true });
</script>

<style lang="scss" scoped>
.chart-container {
--chart-margin: 80px;
width: 100%;
height: calc(100dvh - 2 * var(--chart-margin));
display: flex;
flex-wrap: wrap;
}

#chartWrapper {
width: 100%;
height: 100%;
flex: 1 1 auto;
margin: var(--chart-margin);
}
</style>
5 changes: 3 additions & 2 deletions src/utils/plotConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ const axisConfiguration = (
}
];

export const margins = (rowDimension: Dimension) => ({ left: yAxisNeedsExtraSpace(rowDimension) ? 170 : 110 });

export const plotConfiguration = (
rowDimension: Dimension,
logScaleEnabled: boolean,
Expand All @@ -128,7 +130,6 @@ export const plotConfiguration = (
} => {
const numScales = numericalScales(logScaleEnabled, lines);
const catScales = categoricalScales(lines);
const margins = { left: yAxisNeedsExtraSpace(rowDimension) ? 170 : 110 };
const tickConfig = tickConfiguration(logScaleEnabled, rowDimension);
const constructorOptions = {
tickConfig,
Expand All @@ -141,7 +142,7 @@ export const plotConfiguration = (
return {
constructorOptions,
axisConfig,
chartAppendConfig: [numScales, {}, catScales, margins],
chartAppendConfig: [numScales, {}, catScales, margins(rowDimension)],
categoricalScales: catScales,
};
};
26 changes: 26 additions & 0 deletions tests/unit/components/AppHeader.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia';
import AppHeader from '@/components/AppHeader.vue';

describe('AppHeader component', () => {
beforeEach(() => {
setActivePinia(createPinia());
});

it('renders the about modal when the about button is clicked', async () => {
const wrapper = mount(AppHeader);
expect(wrapper.findComponent({ name: 'FwbModal' }).exists()).toBe(false);

const aboutButton = wrapper.find('button#aboutLink');
await aboutButton.trigger('click');

await vi.waitFor(() => {
expect(wrapper.findComponent({ name: 'FwbModal' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'FwbModal' }).isVisible()).toBe(true);
})

// Check that the version number is displayed correctly
expect(wrapper.text()).toMatch(/Vaxviz version: \d+\.\d+\.\d+/);
});
});
57 changes: 53 additions & 4 deletions tests/unit/components/ColorLegend.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@ import { globalOption } from '@/utils/options';
import { useAppStore } from "@/stores/appStore";
import { useColorStore } from '@/stores/colorStore';

const expectCorrectMarginForRowDimension = (rowDimension: "disease" | "location", wrapper: ReturnType<typeof mount>) => {
const leftMarginPx = rowDimension === "location" ? 170 : 110;
expect(wrapper.find('ul').attributes('style')).toBe(`margin-left: ${leftMarginPx}px;`);
};

describe('RidgelinePlot component', () => {
beforeEach(() => {
setActivePinia(createPinia());
});

it('renders the correct labels and colors when the color dimension is location, sorting them by resolution', async () => {
it('renders the correct labels and colours when the color dimension is location, sorting them by resolution', async () => {
const appStore = useAppStore();
const colorStore = useColorStore();
const wrapper = mount(ColorLegend);
Expand Down Expand Up @@ -61,6 +66,8 @@ describe('RidgelinePlot component', () => {
expect(labels[2].text()).toBe('All 117 VIMC countries');
expect(colorBoxes[2].element.style.borderColor).toBe("rgb(105, 41, 196)"); // purple70
expect(colorBoxes[2].element.style.backgroundColor).toBe("rgba(105, 41, 196, 0.2)");

expectCorrectMarginForRowDimension("disease", wrapper);
});

it('renders the correct labels and colors when the color dimension is disease, in the order in which the colors were assigned', async () => {
Expand Down Expand Up @@ -98,6 +105,8 @@ describe('RidgelinePlot component', () => {
expect(labels[1].text()).toBe('Cholera');
expect(colorBoxes[1].element.style.borderColor).toBe("rgb(105, 41, 196)"); // purple70
expect(colorBoxes[1].element.style.backgroundColor).toBe("rgba(105, 41, 196, 0.2)");

expectCorrectMarginForRowDimension("disease", wrapper);
});

it('updates the legend when the data changes', async () => {
Expand Down Expand Up @@ -143,9 +152,8 @@ describe('RidgelinePlot component', () => {
expect(colorBoxes[2].element.style.borderColor).toBe("rgb(105, 41, 196)"); // purple70
expect(colorBoxes[2].element.style.backgroundColor).toBe("rgba(105, 41, 196, 0.2)");

appStore.dimensions.withinBand = 'location';
appStore.dimensions.column = 'activity_type';
appStore.dimensions.row = 'disease';
expectCorrectMarginForRowDimension("disease", wrapper);

appStore.filters = {
location: ['AFG'],
disease: ['Cholera', 'Rubella'],
Expand Down Expand Up @@ -175,5 +183,46 @@ describe('RidgelinePlot component', () => {
expect(diseaseLabels[1].text()).toBe('Cholera');
expect(diseaseColorBoxes[1].element.style.borderColor).toBe("rgb(105, 41, 196)"); // purple70
expect(diseaseColorBoxes[1].element.style.backgroundColor).toBe("rgba(105, 41, 196, 0.2)");
expectCorrectMarginForRowDimension("disease", wrapper);
});

it('renders the correct labels and colours when the _row_ dimension is location (regardless of color dimension)', async () => {
const appStore = useAppStore();
const colorStore = useColorStore();
const wrapper = mount(ColorLegend);
appStore.dimensions.withinBand = 'disease';
appStore.dimensions.column = null;
appStore.dimensions.row = 'location';
appStore.filters = {
location: ['AFG', 'global'],
disease: ['Malaria'],
};
expect(colorStore.colorDimension).toBe('location');

colorStore.setColors([
{ metadata: { withinBand: 'Malaria', row: 'AFG' } },
{ metadata: { withinBand: 'Malaria', row: 'global' } },
]);

expect(colorStore.colorMapping.size).toBe(2);

await vi.waitFor(() => {
expect(wrapper.findAll(".legend-label").length).toBe(2);
});

const labels = wrapper.findAll(".legend-label");
const colorBoxes = wrapper.findAll(".legend-color-box");

// NB order of locations is _not_ determined by geographical resolution in this case, because the y-categorical scale
// is location and thus the color legend order is intended to match that.
expect(labels[0].text()).toBe('All 117 VIMC countries');
expect(colorBoxes[0].element.style.borderColor).toBe("rgb(105, 41, 196)"); // purple70
expect(colorBoxes[0].element.style.backgroundColor).toBe("rgba(105, 41, 196, 0.2)");

expect(labels[1].text()).toBe('Afghanistan');
expect(colorBoxes[1].element.style.borderColor).toBe("rgb(0, 157, 154)"); // teal50
expect(colorBoxes[1].element.style.backgroundColor).toBe("rgba(0, 157, 154, 0.2)");

expectCorrectMarginForRowDimension("location", wrapper);
});
});