Skip to content

Commit

Permalink
Merge pull request #1040 from hivtools/nm-93
Browse files Browse the repository at this point in the history
[NM-93] Add cascade plot
  • Loading branch information
r-ash authored Nov 28, 2024
2 parents d095931 + 5712f8b commit 1c54703
Show file tree
Hide file tree
Showing 18 changed files with 203 additions and 26 deletions.
3 changes: 3 additions & 0 deletions src/app/static/src/app/components/modelOutput/ModelOutput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
<Table class="col-md-9" v-if="selectedPlot === 'table'"
:plotName="'table'"
:download-enabled="true"/>
<barchart class="col-md-9" v-if="selectedPlot === 'cascade'"
:plot="selectedPlot"
:show-error-bars="true"/>
</div>
</div>
</template>
Expand Down
1 change: 0 additions & 1 deletion src/app/static/src/app/components/plots/bar/Barchart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,6 @@ export default defineComponent({
borderWidth: 2,
borderDash: [5],
drawTime: 'beforeDatasetsDraw'
}
}
}
Expand Down
20 changes: 17 additions & 3 deletions src/app/static/src/app/components/plots/bar/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface ErrorBars {
}
}

type ChartDataSetsWithErrors<Data = BarChartDefaultData> = BarChartDataset<Data> & {
export type ChartDataSetsWithErrors<Data = BarChartDefaultData> = BarChartDataset<Data> & {
errorBars?: ErrorBars,
tooltipExtraText: string[]
}
Expand All @@ -35,14 +35,14 @@ export interface BarChartData<Data = BarChartDefaultData> extends ChartDataWithE
* @param areaLevel The current area level
* @param disaggregateSelections The selected options for the disaggregate control
* @param xAxisSelections The selected options for the x-axis control
* @param xAxisOptions The avaialble options for the x-axis control. We need this as we want
* @param xAxisOptions The available options for the x-axis control. We need this as we want
* to show the x-axis in the same order as the options are in the dropdown
* @param areaIdToLevelMap Mapping of area IDs to area levels
* @return Data required for chartJS. This includes:
* labels - array of labels for the x-axis
* datasets - array of datasets, each of them represents a bar in the barchart, with a label, a colour,
* the data values themselves and error bars
* maxValuePlusError - value for the highest value in the barchart + error, used for the height of the plot
* maxValuePlusError - value for the highest value in the barchart + error, used for the height of the plot
*/
export const plotDataToChartData = function (plotData: PlotData,
indicatorMetadata: IndicatorMetadata,
Expand Down Expand Up @@ -357,6 +357,20 @@ const initialBarChartDataset = (datasetLabel: string, backgroundColor: string):
}
};

export const sortDatasets = (datasets: ChartDataSetsWithErrors[], disaggregateSelections: FilterOption[], order: string[]) => {
const labelIdxMap = new Map<string, number>();
order.forEach((id: string, index) => {
const filterOpt = disaggregateSelections.find((opt: FilterOption) => opt.id === id)
if (filterOpt?.label) labelIdxMap.set(filterOpt.label, index)
});

const getLabelIndex = (label?: string): number => labelIdxMap.get(label ?? "") ?? Infinity;

datasets.sort((a: ChartDataSetsWithErrors, b: ChartDataSetsWithErrors) => {
return getLabelIndex(a.label) - getLabelIndex(b.label);
});
}

const colors = [
//d3 chromatic schemeSet1
'#e41a1c',
Expand Down
44 changes: 44 additions & 0 deletions src/app/static/src/app/generated.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,50 @@ export interface CalibrateMetadataResponse {
hidden?: boolean;
}[];
};
cascade: {
defaultEffect?: {
setFilters?: {
filterId: string;
label: string;
stateFilterId: string;
}[];
setMultiple?: string[];
setFilterValues?: {
[k: string]: string[];
};
setHidden?: string[];
customPlotEffect?: {
row: string[];
column: string[];
};
};
plotSettings: {
id: string;
label: string;
options: {
id: string;
label: string;
effect: {
setFilters?: {
filterId: string;
label: string;
stateFilterId: string;
}[];
setMultiple?: string[];
setFilterValues?: {
[k: string]: string[];
};
setHidden?: string[];
customPlotEffect?: {
row: string[];
column: string[];
};
};
}[];
value?: string;
hidden?: boolean;
}[];
};
};
warnings: {
text: string;
Expand Down
3 changes: 2 additions & 1 deletion src/app/static/src/app/hintVersion.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const currentHintVersion = "3.11.6";
export const currentHintVersion = "3.12.0";

8 changes: 7 additions & 1 deletion src/app/static/src/app/store/modelCalibrate/getters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,11 @@ export const getters = {
tableMetadata = metadata.plotSettingsControl[plotName].defaultEffect?.customPlotEffect
}
return tableMetadata
}
},
cascadePlotIndicators: (state: ModelCalibrateState) => {
// In the cascade plot, we want to show the bars in the specified order, as we want the bars to
// actually "cascade". This is in contrast to normal plots in which we show the selected in order
// that is in the drop down.
return state.metadata?.plotSettingsControl.cascade.defaultEffect?.setFilterValues?.indicator
}
};
25 changes: 21 additions & 4 deletions src/app/static/src/app/store/plotSelections/getters.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import {ControlSelection, FilterSelection, PlotName, PlotSelectionsState} from "./plotSelections";
import {IndicatorMetadata, FilterOption, InputComparisonData} from "../../generated";
import {BarChartData, inputComparisonPlotDataToChartData, plotDataToChartData} from "../../components/plots/bar/utils";
import {
BarChartData,
ChartDataSetsWithErrors,

Check warning on line 5 in src/app/static/src/app/store/plotSelections/getters.ts

View workflow job for this annotation

GitHub Actions / test

'ChartDataSetsWithErrors' is defined but never used. Allowed unused vars must match /^_/u
inputComparisonPlotDataToChartData,
plotDataToChartData,
sortDatasets
} from "../../components/plots/bar/utils";
import {RootState} from "../../root";
import {PlotData} from "../plotData/plotData";
import {Dict} from "../../types";
Expand All @@ -25,7 +31,7 @@ export const getters = {
filterSelections: FilterSelection[], currentLanguage: string): BarChartData => {

const metadata = getMetadataFromPlotName(rootState, plotName);
if (plotName == "inputComparisonBarchart") {
if (plotName === "inputComparisonBarchart") {
const xAxisId = "year";
const xAxisSelections = getters.filterSelectionFromId(plotName, xAxisId);
const xAxisOptions = metadata.filterTypes.find(f => f.id === xAxisId)!.options;
Expand All @@ -47,15 +53,26 @@ export const getters = {
// can just keep these as empty.
let areaIdToLevelMap = {} as Dict<number>;
let areaLevel = null;
if (plotName === "barchart" || plotName === "comparison") {
if (plotName === "barchart" || plotName === "comparison" || plotName === "cascade") {
areaIdToLevelMap = rootGetters["baseline/areaIdToLevelMap"];
areaLevel = filterSelections.find(f => f.filterId == "detail")?.selection[0]?.id;
}
if (disaggregateId && xAxisId && xAxisOptions) {
return plotDataToChartData(plotData, indicatorMetadata,
const data = plotDataToChartData(plotData, indicatorMetadata,
disaggregateId, disaggregateSelections,
xAxisId, xAxisSelections, xAxisOptions,
areaLevel, areaIdToLevelMap);
if (plotName === "cascade") {
// This is a bit ugly that it is here...
// In general this code is becoming a bit hard to follow, perhaps worth refactoring at some point?
// We want to sort the datasets here based on the order in the disaggregate selections
// The data is fetched in the order of the filter, and then shown in this order. This is fine for
// most plots but we want to override the order here and hard code it so that the indicators are shown
// in the order specified in the "cascade" instead of in the default indicator order
const indicatorOrder = rootGetters["modelCalibrate/cascadePlotIndicators"];
sortDatasets(data.datasets, disaggregateSelections, indicatorOrder)
}
return data
} else {
return emptyData
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type InputPlotName = keyof ReviewInputFilterMetadataResponse["plotSetting
keyof InputComparisonResponse["metadata"]["plotSettingsControl"]
export type CalibratePlotName = keyof CalibratePlotResponse["metadata"]["plotSettingsControl"]

export const outputPlotNames: OutputPlotName[] = ["choropleth", "barchart", "table", "comparison", "bubble"];
export const outputPlotNames: OutputPlotName[] = ["choropleth", "barchart", "table", "comparison", "bubble", "cascade"];
export const inputPlotNames: InputPlotName[] = ["timeSeries", "inputChoropleth", "inputComparisonTable", "inputComparisonBarchart"];
export const calibratePlotName: CalibratePlotName = "calibrate";

Expand All @@ -32,6 +32,7 @@ export const plotNameToDataType: Record<PlotName, PlotDataType> = {
bubble: PlotDataType.Output,
choropleth: PlotDataType.Output,
table: PlotDataType.Output,
cascade: PlotDataType.Output,
timeSeries: PlotDataType.TimeSeries,
inputChoropleth: PlotDataType.InputChoropleth,
calibrate: PlotDataType.Calibrate,
Expand Down
4 changes: 1 addition & 3 deletions src/app/static/src/app/store/plotSelections/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,7 @@ export const commitPlotDefaultSelections = async (
});
});
payload.selections.controls = selectedSettingOptions

const filtersInfo = filtersInfoFromEffects(effects, rootState, metadata);
payload.selections.filters = filtersInfo;
payload.selections.filters = filtersInfoFromEffects(effects, rootState, metadata);

await getPlotData(payload, commit, rootState);

Expand Down
4 changes: 4 additions & 0 deletions src/app/static/src/app/store/translations/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export type Translations = {
cancelEdit: string,
cancelEditLoggedIn: string,
cancelFitting: string,
cascade: string,
clearText: string,
clickHere: string,
close: string,
Expand Down Expand Up @@ -431,6 +432,7 @@ const en: Translations = {
cancelEdit: "Cancel editing so I can save my work",
cancelEditLoggedIn: "Cancel editing",
cancelFitting: "Cancel fitting",
cascade: "Cascade",
clearText: "Clear",
clickHere: "Click here",
close: "Close",
Expand Down Expand Up @@ -822,6 +824,7 @@ const fr: Partial<Translations> = {
cancelEdit: "Annuler l'édition pour que je puisse sauvegarder mon travail",
cancelEditLoggedIn: "Annuler l'édition",
cancelFitting: "Annuler l'ajustement",
cascade: "Cascade",
clearText: "Effacer",
clickHere: "Cliquer ici",
close: "Fermer",
Expand Down Expand Up @@ -1212,6 +1215,7 @@ const pt: Partial<Translations> = {
cancelEdit: "Cancelar a edição para que eu possa guardar o meu trabalho",
cancelEditLoggedIn: "Cancelar edição",
cancelFitting: "Cancelar encaixe",
cascade: "Cascata",
clearText: "Limpar",
clickHere: "Clique aqui",
close: "Fechar",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,13 @@ describe("Model Output page", () => {
const wrapper = getWrapper(store);

const plotTabs = wrapper.findAll(".nav-link");
expect(plotTabs.length).toBe(5);
expect(plotTabs.length).toBe(6);
expect(plotTabs[0].classes()).contains("active");
expect(plotTabs[1].classes()).not.contains("active");
expect(plotTabs[2].classes()).not.contains("active");
expect(plotTabs[3].classes()).not.contains("active");
expect(plotTabs[4].classes()).not.contains("active");
expect(plotTabs[5].classes()).not.contains("active");
expect(wrapper.findComponent(PlotControlSet).exists()).toBeTruthy();
expect(wrapper.findComponent(FilterSet).exists()).toBeTruthy();
expect(wrapper.findComponent(Choropleth).exists()).toBeTruthy();
Expand Down
6 changes: 6 additions & 0 deletions src/app/static/src/tests/e2e/outputPlots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,10 @@ test("can view output plots", async ({ projectPage }) => {

// bubble plot is updated
await expect(page.locator("#review-output")).toHaveScreenshot("bubble-reset.png");

// When I switch to cascade tab
await page.getByText('Cascade').click();

// Cascade plot is shown
await expect(page.locator("#review-output")).toHaveScreenshot("cascade-landing.png");
})
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/app/static/src/tests/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,9 @@ export const mockCalibrateMetadataResponse = (props: Partial<CalibrateMetadataRe
bubble: {
plotSettings: []
},
cascade: {
plotSettings: []
}
},
warnings: [],
...props
Expand Down
20 changes: 14 additions & 6 deletions src/app/static/src/tests/modelCalibrate/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ describe("ModelCalibrate actions", () => {

await actions.getResult({commit, state, rootState, dispatch} as any);

expect(commit.mock.calls.length).toBe(9);
expect(commit.mock.calls.length).toBe(10);
expect(commit.mock.calls[0][0]).toStrictEqual({
type: "MetadataFetched",
payload: testResult
Expand Down Expand Up @@ -230,28 +230,36 @@ describe("ModelCalibrate actions", () => {
filters: []
}
});
expect(commit.mock.calls[5][0]).toBe("plotSelections/updatePlotSelection");
expect(commit.mock.calls[5][1]["payload"]).toStrictEqual({
plot: "cascade",
selections: {
controls: [],
filters: []
}
});

// Commits initial scale selections
expect(commit.mock.calls[5][0]).toStrictEqual({
expect(commit.mock.calls[6][0]).toStrictEqual({
type: "plotState/setOutputScale",
payload: {
scale: Scale.Colour,
selections: {}
}
});
expect(commit.mock.calls[6][0]).toStrictEqual({
expect(commit.mock.calls[7][0]).toStrictEqual({
type: "plotState/setOutputScale",
payload: {
scale: Scale.Size,
selections: {}
}
});

expect(commit.mock.calls[7][0]).toBe("Calibrated");
expect(commit.mock.calls[8][0]).toBe("Ready");
expect(commit.mock.calls[8][0]).toBe("Calibrated");
expect(commit.mock.calls[9][0]).toBe("Ready");

// Dispatches to get plot data
expect(getOutputFilteredDataSpy.mock.calls.length).toBe(4) // The number of plots we have
expect(getOutputFilteredDataSpy.mock.calls.length).toBe(5) // The number of plots we have
expect(dispatch.mock.calls[0][0]).toBe("getCalibratePlot");
expect(dispatch.mock.calls[1][0]).toBe("getComparisonPlot");
});
Expand Down
39 changes: 39 additions & 0 deletions src/app/static/src/tests/modelCalibrate/getters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ describe("modelCalibrate getters", () => {
bubble: {
plotSettings: []
},
cascade: {
plotSettings: []
},
}
})
const calibrateState = mockModelCalibrateState({
Expand Down Expand Up @@ -196,6 +199,9 @@ describe("modelCalibrate getters", () => {
bubble: {
plotSettings: []
},
cascade: {
plotSettings: []
},
}
})
const calibrateState = mockModelCalibrateState({
Expand All @@ -217,4 +223,37 @@ describe("modelCalibrate getters", () => {

expect(getter("table")).toBe(undefined);
});

it("can get indicator for cascade plot in hard coded order", () => {
const calibrateResponse = mockCalibrateMetadataResponse({
plotSettingsControl: {
choropleth: {
plotSettings: []
},
barchart: {
plotSettings: []
},
table: {
plotSettings: []
},
bubble: {
plotSettings: []
},
cascade: {
defaultEffect: {
setFilterValues: {
indicator: ["a", "b"]
}
},
plotSettings: []
},
}
});
const state = mockModelCalibrateState({
metadata: calibrateResponse
});
const indicator = getters.cascadePlotIndicators(state);

expect(indicator).toStrictEqual(["a", "b"]);
});
});
3 changes: 3 additions & 0 deletions src/app/static/src/tests/plotSelections/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,9 @@ describe("Projects actions", () => {
bubble: {
plotSettings: []
},
cascade: {
plotSettings: []
},
}
});
const rootState = mockRootState({
Expand Down
Loading

0 comments on commit 1c54703

Please sign in to comment.