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
257 changes: 134 additions & 123 deletions package-lock.json

Large diffs are not rendered by default.

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": "2.0.0",
"version": "2.0.1",
"private": true,
"type": "module",
"engines": {
Expand Down
28 changes: 14 additions & 14 deletions src/components/RidgelinePlot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -93,53 +93,53 @@ const relevantRidgeLines = computed(() => {
if (appStore.focuses.length !== 1 || !eachRowRepresentsADisease) {
return ridgeLines.value;
};

// If data for a disease does not exist for the focused location, nor the corresponding subregion,
// we should consider the disease irrelevant and exclude its entire row from the plot.
// E.g. Say the focus value is 'Afghanistan'. For some row of ridgelines (where rows are diseases, such as malaria)
// there may be data available at a global level, but none for Afghanistan or its subregion Southern Asia.
// In such cases we should consider the malaria row irrelevant to the focus location, and exclude all the lines within the row,
// Check if any of the locations for the current line's disease match either the focus location or its subregion.
const focusLocation = appStore.focuses[0];
const focusLocation = appStore.focuses[0]!;
return ridgeLines.value.filter((line) => {
const disease = line.metadata?.[Axis.ROW];
const disease = line.metadata[Axis.ROW];
const locationsForDisease = ridgeLines.value
.filter(l => l.metadata?.[Axis.ROW] === disease) // Get all lines whose disease matches that of the current line.
.map(({ metadata }) => metadata?.withinBand); // Look up their locations.
// Check if any of the locations for the current line's disease match either the focus location or its subregion.
return [focusLocation, getSubregionFromCountry(focusLocation)].some(loc => locationsForDisease.includes(loc));
.filter(l => l.metadata[Axis.ROW] === disease) // Get all lines whose disease matches that of the current line.
.map(({ metadata }) => metadata[Axis.WITHIN_BAND]); // Look up their locations.
// Check if any of the locations for the current line's disease match either the focus location or its subregion.
return [focusLocation, getSubregionFromCountry(focusLocation)].some(loc => locationsForDisease.includes(loc));
});
});

// A plot-row may contain multiple ridgelines.
// Return the mean of the means for all ridgelines in the plot row.
// The plot row is specified by its category along the relevant dimension, e.g. 'Djibouti' (a location) or 'Malaria' (a disease).
const getMeanOfMeansForPlotRow = (plotRowCategory?: string) => {
const ridgelinesForPlotRow = relevantRidgeLines.value.filter(line => line.metadata?.[Axis.ROW] === plotRowCategory);
const ridgelinesForPlotRow = relevantRidgeLines.value.filter(line => line.metadata[Axis.ROW] === plotRowCategory);
const meanValues = ridgelinesForPlotRow.map(({ metadata }) => {
const dataTableRow = dataStore.getSummaryDataRow(metadata!)!;
const dataTableRow = dataStore.getSummaryDataRow(metadata)!;
return dataTableRow[SummaryTableColumn.MEAN];
});
return meanValues.reduce((sum, val) => sum + val, 0) / meanValues.length;
Comment thread
david-mears-2 marked this conversation as resolved.
};

const sortedRidgeLines = computed(() => relevantRidgeLines.value.toSorted((lineA, lineB) => {
const meanA = getMeanOfMeansForPlotRow(lineA.metadata?.[Axis.ROW]);
const meanB = getMeanOfMeansForPlotRow(lineB.metadata?.[Axis.ROW]);
const meanA = getMeanOfMeansForPlotRow(lineA.metadata[Axis.ROW]);
const meanB = getMeanOfMeansForPlotRow(lineB.metadata[Axis.ROW]);
return meanA - meanB;
}));

// Apply the filtering specified by the legend selections.
const selectedLines = computed(() => sortedRidgeLines.value.filter(line => {
const colorVal = line.metadata?.[colorStore.colorAxis];
const colorVal = line.metadata[colorStore.colorAxis];
return colorVal && appStore.legendSelections[colorStore.colorDimension]?.includes(colorVal);
}));

// Debounce chart updates so that there is no flickering if filters change at a different moment from focus/dimensions.
const updateChart = debounce(() => {
helpInfoStore.showNegativeValuesHelpInfo = false;

const categoriesInUse = relevantRidgeLines.value.flatMap(line => Object.keys(appStore.dimensions).map(axis => line.metadata?.[axis as Axis]));
const categoriesInUse = relevantRidgeLines.value.flatMap(line => Object.keys(appStore.dimensions).map(axis => line.metadata[axis as Axis]));
focusesWithoutData.value = appStore.focuses.filter(focus => !categoriesInUse.includes(focus));

noDataToDisplay.value = selectedLines.value.length === 0;
Expand All @@ -153,7 +153,7 @@ const updateChart = debounce(() => {
colorStore.setColors(sortedRidgeLines.value);

const lines = selectedLines.value.map(line => {
const { fillColor, fillOpacity, strokeColor, strokeOpacity } = colorStore.getColorsForLine(line.metadata!);
const { fillColor, fillOpacity, strokeColor, strokeOpacity } = colorStore.getColorsForLine(line.metadata);

return { ...line, style: { strokeWidth: 1, opacity: strokeOpacity, fillOpacity, strokeColor, fillColor } };
});
Expand Down
11 changes: 5 additions & 6 deletions src/composables/useHistogramLines.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Axis, Dimension, HistColumn, type Coords, type HistDataRow, type LineMetadata } from '@/types';
import type { LineConfig, Lines } from 'types';
import { Axis, Dimension, HistColumn, type LineWithMetadata, type Coords, type HistDataRow, type LineMetadata } from '@/types';
import { computed, toValue, type MaybeRefOrGetter } from 'vue';

// Construct histogram/ridge-shaped lines by building area lines whose points trace the
// outline of the histogram.
// outline of the histogram.
export default (
data: MaybeRefOrGetter<HistDataRow[]>,
axisDimensions: () => {
Expand Down Expand Up @@ -33,7 +32,7 @@ export default (
const initializeLine = (
barCoords: Coords[],
categoryValues: LineMetadata,
): Lines<LineMetadata>[0] => {
): LineWithMetadata => {
return {
points: barCoords,
bands: {
Expand All @@ -48,11 +47,11 @@ export default (

// Construct histogram/ridge-shaped lines by building area lines whose points trace the
// outline of the histogram bars (including the spaces in between them).
const constructLines = (): Lines<LineMetadata> => {
const constructLines = (): LineWithMetadata[] => {
// A 3-dimensional dictionary of lines.
// We use x-value as the key at the first level, then y-value on the second, then withinBandValue.
// If the x-value (or anything else) is undefined, then the key should be an empty string.
const lines: Record<string, Record<string, Record<string, LineConfig<LineMetadata>>>> = {};
const lines: Record<string, Record<string, Record<string, LineWithMetadata>>> = {};

toValue(data).forEach(dataRow => {
// Each line needs to know its category for each categorical axis in use.
Expand Down
6 changes: 4 additions & 2 deletions src/composables/usePlotTooltips.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useAppStore } from "@/stores/appStore";
import { useColorStore } from "@/stores/colorStore";
import { useDataStore } from "@/stores/dataStore";
import { Axis, BurdenMetric, SummaryTableColumn, type PointWithMetadata } from "@/types";
import { Axis, BurdenMetric, type LineMetadata, SummaryTableColumn } from "@/types";
import { dimensionOptionLabel } from "@/utils/options";
import sentenceCase from "@/utils/sentenceCase";
import { type Point } from "@reside-ic/skadi-chart";

export default () => {
const colorStore = useColorStore();
Expand All @@ -22,7 +23,8 @@ export default () => {

// 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) => {
// skadi-chart's types dictate the function signature: the 'point' parameter's metadata prop must be optional.
const tooltipCallback = (point: Point & { metadata?: LineMetadata }) => {
if (!point.metadata) return "";

const { strokeColor } = colorStore.getColorsForLine(point.metadata)
Expand Down
7 changes: 3 additions & 4 deletions src/stores/colorStore.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import type { Lines } from "@reside-ic/skadi-chart";
import convert, { type HEX } from "color-convert";
import { Axis, type LineColors, type LineMetadata } from "@/types";
import { Axis, type LineColors, type LineMetadata, type LineWithMetadata } from "@/types";
import { useAppStore } from "@/stores/appStore";
import { globalOption } from "@/utils/options";

Expand Down Expand Up @@ -84,11 +83,11 @@ export const useColorStore = defineStore("color", () => {
const colorMapping = computed(() => mapping.value as ReadonlyMap<string, string>);

// Based on an array of all the lines, set up the color mapping.
const setColors = (lines: Lines<LineMetadata>) => {
const setColors = (lines: LineWithMetadata[]) => {
mapping.value = new Map<string, string>()

// A 'value' refers to the specific location or disease whose color we need to assign.
const uniqueValues = Array.from(new Set(lines.map(line => line.metadata?.[colorAxis.value]))) as string[];
const uniqueValues = Array.from(new Set(lines.map(line => line.metadata[colorAxis.value])));
Comment thread
david-mears-2 marked this conversation as resolved.
let palette: string[] = [];
if (palettesByCategoryCount[uniqueValues.length]) {
// Use a specific palette designed for this number of categories.
Expand Down
2 changes: 1 addition & 1 deletion src/stores/dataStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const useDataStore = defineStore("data", () => {
const getSummaryDataRow = (metadata: LineMetadata) => {
return summaryTableData.value.find(d => {
return Object.entries(appStore.dimensions).every(([axis, dim]) => {
return !dim || d[dim] === metadata?.[axis as Axis];
return !dim || d[dim] === metadata[axis as Axis];
});
});
};
Expand Down
5 changes: 2 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Point } from "@reside-ic/skadi-chart";
import type { Lines } from "@reside-ic/skadi-chart";

export enum BurdenMetric {
DALYS = "dalys",
Expand Down Expand Up @@ -68,8 +68,7 @@ export enum Axis {

// Metadata associated with each line in a ridgeline plot, which skadi-chart copies onto each line's points.
export type LineMetadata = Record<Axis, string>;

export type PointWithMetadata = Point & { metadata?: LineMetadata };
export type LineWithMetadata = Lines<LineMetadata>[0] & { metadata: LineMetadata } // Make metadata non-optional

export type LineColors = {
fillColor: string | undefined;
Expand Down
14 changes: 7 additions & 7 deletions src/utils/plotConfiguration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Dimension, type LineMetadata } from "@/types";
import { Dimension, type LineWithMetadata } from "@/types";
import { globalOption } from "./options";
import type { Lines, Scales } from "@reside-ic/skadi-chart";
import type { Scales } from "@reside-ic/skadi-chart";
// "Chart" and "types" are modules declared by @reside-ic/skadi-chart
import type { PartialChartOptions } from "Chart";
import type { Bounds, XY } from "types";
Expand All @@ -10,7 +10,7 @@ const ELLIPSIS = "...";
const Y_TICK_LABEL_MAX_LENGTH = globalOption.label.length;
export const TOOLTIP_RADIUS_PX = 100; // Maximum distance in px from point for triggering tooltips to be displayed

const numericalScales = (logScaleEnabled: boolean, lines: Lines<LineMetadata>): Scales => {
const numericalScales = (logScaleEnabled: boolean, lines: LineWithMetadata[]): Scales => {
const maxX = Math.max(...lines.flatMap(l => {
const lastPoint = l.points[l.points.length - 1]!;
return lastPoint.x;
Expand Down Expand Up @@ -40,7 +40,7 @@ const numericalScales = (logScaleEnabled: boolean, lines: Lines<LineMetadata>):
// do not matter absolutely, but rather they only matter relatively to each other _within_ a given line.
// That is, it's the shape of the line that is of interest. Without normalization, flat lines become harder
// to see when spikier lines push up the top end of a common y-axis scale.
const normalizeLines = (lines: Lines<LineMetadata>, yMax: number): Lines<LineMetadata> => {
const normalizeLines = (lines: LineWithMetadata[], yMax: number): LineWithMetadata[] => {
const yMaxWithPadding = yMax * 0.9; // Add padding to the max y value to prevent lines from touching the top of the plot after normalization.
return lines.map(line => {
const lineMaxY = Math.max(...line.points.map(p => p.y));
Expand All @@ -52,7 +52,7 @@ const normalizeLines = (lines: Lines<LineMetadata>, yMax: number): Lines<LineMet
});
};

const categoricalScales = (lines: Lines<LineMetadata>): Partial<XY<string[]>> => {
const categoricalScales = (lines: LineWithMetadata[]): Partial<XY<string[]>> => {
// Assume that the lines passed in have already been sorted for the y-axis.
const xCategoricalScale = [...new Set(lines.map(l => l.bands?.x).filter(c => !!c))] as string[];
const yCategoricalScale = [...new Set(lines.map(l => l.bands?.y).filter(c => !!c))] as string[];
Expand Down Expand Up @@ -155,14 +155,14 @@ const margins = (rowDimension: Dimension) => ({ left: yAxisNeedsExtraSpace(rowDi
export const plotConfiguration = (
rowDimension: Dimension,
logScaleEnabled: boolean,
lines: Lines<LineMetadata>,
lines: LineWithMetadata[],
normalizeYScale: boolean,
): {
constructorOptions: PartialChartOptions
axisConfig: AxisConfig
chartAppendConfig: [Partial<Scales>, Partial<Scales>, Partial<XY<string[]>>, Partial<Bounds["margin"]>]
numericalScales: Scales
normalizedLines: Lines<LineMetadata>
normalizedLines: LineWithMetadata[]
} => {
const numScales = numericalScales(logScaleEnabled, lines);
const catScales = categoricalScales(lines);
Expand Down
19 changes: 10 additions & 9 deletions tests/unit/composables/usePlotTooltips.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import usePlotTooltips from '@/composables/usePlotTooltips';
import { useAppStore } from '@/stores/appStore';
import { useColorStore } from '@/stores/colorStore';
import { useDataStore } from '@/stores/dataStore';
import { Axis, BurdenMetric, Dimension, SummaryTableColumn, type PointWithMetadata, type SummaryTableDataRow } from '@/types';
import { Axis, BurdenMetric, Dimension, LineMetadata, SummaryTableColumn, type SummaryTableDataRow } from '@/types';
import type { PointWithMetadata } from "types";

describe('usePlotTooltips', () => {
beforeEach(() => {
Expand All @@ -17,7 +18,7 @@ describe('usePlotTooltips', () => {
describe('tooltipCallback', () => {
it('returns empty string when point has no metadata', () => {
const { tooltipCallback } = usePlotTooltips();
const point: PointWithMetadata = { x: 1, y: 2 };
const point: PointWithMetadata<LineMetadata> = { x: 1, y: 2 };

expect(tooltipCallback(point)).toBe('');
});
Expand All @@ -40,15 +41,15 @@ describe('usePlotTooltips', () => {

const { tooltipCallback } = usePlotTooltips();

const afgTooltip = tooltipCallback({ x: 1, y: 2, metadata: afgPointMetadata.metadata! });
const afgTooltip = tooltipCallback({ x: 1, y: 2, metadata: afgPointMetadata.metadata });

expect(afgTooltip).toContain('Location: <b>Afghanistan</b>');
expect(afgTooltip).toContain('style="color: #009d9a'); // teal50
// Row dimension (disease) is shown because it's different from color dimension (location)
expect(afgTooltip).toContain('Disease: <b>Cholera</b>');
expect(afgTooltip).not.toContain('Activity type');

const globalTooltip = tooltipCallback({ x: 1, y: 2, metadata: globalPointMetadata.metadata! });
const globalTooltip = tooltipCallback({ x: 1, y: 2, metadata: globalPointMetadata.metadata });

expect(globalTooltip).toContain('Location: <b>All 117 VIMC countries</b>');
expect(globalTooltip).toContain('style="color: #6929c4'); // purple70
Expand All @@ -74,15 +75,15 @@ describe('usePlotTooltips', () => {

const { tooltipCallback } = usePlotTooltips();

const choleraTooltip = tooltipCallback({ x: 1, y: 2, metadata: choleraPointMetadata.metadata! });
const choleraTooltip = tooltipCallback({ x: 1, y: 2, metadata: choleraPointMetadata.metadata });

expect(choleraTooltip).toContain('Disease: <b>Cholera</b>');
expect(choleraTooltip).toContain('style="color: #6929c4'); // purple70
// Row dimension (disease) is NOT shown separately because it's the same as color dimension
expect(choleraTooltip.match(/Disease:/g)?.length).toBe(1);
expect(choleraTooltip).not.toContain('Activity type');

const measlesTooltip = tooltipCallback({ x: 1, y: 2, metadata: measlesPointMetadata.metadata! });
const measlesTooltip = tooltipCallback({ x: 1, y: 2, metadata: measlesPointMetadata.metadata });

expect(measlesTooltip).toContain('Disease: <b>Measles</b>');
expect(measlesTooltip).toContain('style="color: #009d9a'); // teal50
Expand Down Expand Up @@ -114,14 +115,14 @@ describe('usePlotTooltips', () => {

const { tooltipCallback } = usePlotTooltips();

const routineTooltip = tooltipCallback({ x: 1, y: 2, metadata: routinePointMetadata.metadata! });
const routineTooltip = tooltipCallback({ x: 1, y: 2, metadata: routinePointMetadata.metadata });
expect(routineTooltip).toContain('Disease: <b>Cholera</b>');
expect(routineTooltip).toContain('Activity type: <b>Routine</b>');
// Row dimension (disease) is NOT shown separately because it's the same as color dimension
expect(routineTooltip.match(/Disease:/g)?.length).toBe(1);
expect(routineTooltip).toContain('style="color: #6929c4'); // purple70

const campaignTooltip = tooltipCallback({ x: 1, y: 2, metadata: campaignPointMetadata.metadata! });
const campaignTooltip = tooltipCallback({ x: 1, y: 2, metadata: campaignPointMetadata.metadata });

expect(campaignTooltip).toContain('Disease: <b>Cholera</b>');
expect(campaignTooltip).toContain('Activity type: <b>Campaign</b>');
Expand Down Expand Up @@ -153,7 +154,7 @@ describe('usePlotTooltips', () => {

const { tooltipCallback } = usePlotTooltips();

return tooltipCallback({ x: 1, y: 2, metadata: afgPointMetadata.metadata! });
return tooltipCallback({ x: 1, y: 2, metadata: afgPointMetadata.metadata });
}

it('displays summary data (linear scale)', () => {
Expand Down
Loading