Skip to content

Commit

Permalink
Merge pull request #10 from seroanalytics/spline
Browse files Browse the repository at this point in the history
support spline options
  • Loading branch information
hillalex authored Sep 16, 2024
2 parents 04eecb4 + 6e45355 commit 8c76677
Show file tree
Hide file tree
Showing 22 changed files with 391 additions and 62 deletions.
4 changes: 3 additions & 1 deletion src/RootContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ export const initialState: AppState = {
export enum ActionType {
ERROR_ADDED = "ERROR_ADDED",
ERROR_DISMISSED = "ERROR_DISMISSED",
CLEAR_ALL_ERRORS = "CLEAR_ALL_ERRORS",
UPLOAD_ERROR_ADDED = "UPLOAD_ERROR_ADDED",
UPLOAD_ERROR_DISMISSED = "UPLOAD_ERROR_DISMISSED",
DATASET_NAMES_FETCHED = "DATASET_NAMES_FETCHED",
DATASET_METADATA_FETCHED = "DATASET_METADATA_FETCHED",
DATASET_SELECTED = "DATASET_SELECTED",
SELECT_COVARIATE = "SELECT_COVARIATE",
UNSELECT_COVARIATE = "UNSELECT_COVARIATE",
SELECT_SCALE = "SELECT_SCALE"
SELECT_SCALE = "SELECT_SCALE",
SET_SPLINE_OPTIONS = "SET_SPLINE_OPTIONS",
}

export interface RootAction {
Expand Down
1 change: 1 addition & 0 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default function App() {
}
}, [state.selectedDataset, state.language, dispatch]);


useEffect(() => {
setInterval(() => {
dataService("en", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/ExploreDataset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {RootContext} from "../RootContext";
import {Col, Row} from "react-bootstrap";
import SideBar from "./SideBar";
import LinePlot from "./LinePlot";
import {calculateFacets} from "../services/plotUtils";
import {calculateFacets} from "../services/utils";

export function ExploreDataset() {

Expand Down
12 changes: 7 additions & 5 deletions src/components/LinePlot.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, {useContext, useEffect, useState} from 'react';
import React, {useContext, useState} from 'react';
import Plot from 'react-plotly.js';
import {RootContext, RootDispatchContext} from "../RootContext";
import {DataSeries} from "../generated";
import {dataService} from "../services/dataService";
import {toFilename} from "../services/plotUtils";
import {toFilename} from "../services/utils";
import {useDebouncedEffect} from "../hooks/useDebouncedEffect";

interface Props {
biomarker: string
Expand Down Expand Up @@ -45,11 +46,12 @@ export default function LinePlot({

const covariateSettings = state.datasetSettings[state.selectedDataset].covariateSettings;
const scale = state.datasetSettings[state.selectedDataset].scale;
useEffect(() => {
const splineSettings = state.datasetSettings[state.selectedDataset].splineSettings;
useDebouncedEffect(() => {
const fetchData = async () => {
const result = await dataService(state.language, dispatch)
.getDataSeries(state.selectedDataset,
biomarker, facetDefinition, covariateSettings, scale);
biomarker, facetDefinition, covariateSettings, scale, splineSettings);

if (result && result.data) {
setSeries(result.data)
Expand All @@ -58,7 +60,7 @@ export default function LinePlot({
}
}
fetchData();
}, [state.language, dispatch, state.selectedDataset, biomarker, facetDefinition, covariateSettings, scale]);
}, [state.language, dispatch, state.selectedDataset, biomarker, facetDefinition, covariateSettings, scale, splineSettings], 100);

let series: any[] = [];

Expand Down
5 changes: 5 additions & 0 deletions src/components/SideBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import CovariateOptions from "./CovariateOptions";
import SelectedCovariate from "./SelectedCovariate";
import ChooseDataset from "./ChooseDataset";
import ChooseScale from "./ChooseScale";
import SplineOptions from "./SplineOptions";

export default function SideBar() {

Expand Down Expand Up @@ -39,6 +40,10 @@ export default function SideBar() {
</Col>
</Row>
<ChooseScale />
<Form.Label>
Spline options
</Form.Label>
<SplineOptions />
{availableCovariates.length > 0 &&
<Form.Group className="mb-3">
<Form.Label>Disaggregate by</Form.Label>
Expand Down
82 changes: 82 additions & 0 deletions src/components/SplineOptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, {useContext} from "react";
import {ActionType, RootContext, RootDispatchContext} from "../RootContext";
import Form from "react-bootstrap/Form";
import {Col, Row} from "react-bootstrap";
import {between} from "../services/utils";

export default function SplineOptions() {

const state = useContext(RootContext);
const dispatch = useContext(RootDispatchContext);

const onChangeMethod = (event: any) => {
dispatch({
type: ActionType.SET_SPLINE_OPTIONS,
payload: {method: event.target.value}
});
}

const onChangeSpan = (event: any) => {
dispatch({
type: ActionType.SET_SPLINE_OPTIONS,
payload: {span: between(event.target.value, 0, 1)}
});
}

const onChangeKnots = (event: any) => {
dispatch({
type: ActionType.SET_SPLINE_OPTIONS,
payload: {k: between(event.target.value, 5, 30)}
});
}

const settings = state.datasetSettings[state.selectedDataset].splineSettings;

return <Form.Group>
<Row className={"mt-2"}>
<Form.Label column sm="6" htmlFor="method">
Method:
</Form.Label>
<Col sm="6">
<Form.Select role="listbox"
name="method"
value={settings.method} onChange={onChangeMethod}>
<option>auto</option>
<option>gam</option>
<option>loess</option>
</Form.Select>
</Col>
</Row>
<Row className={"mt-2"}>
<Form.Label column sm="6" htmlFor="span">
Span:
</Form.Label>
<Col sm="6">
<Form.Range min={0} max={1} step={0.05} value={settings.span}
onChange={onChangeSpan}/>
<Form.Control type={"number"}
name={"span"}
value={settings.span}
min={0}
max={1}
step={0.05}
onChange={onChangeSpan}/>
</Col>
</Row>
<Row className={"mt-2"}>
<Form.Label column sm="6" htmlFor="k">
k:
</Form.Label>
<Col sm="6">
<Form.Range min={5} max={30} step={1} value={settings.k}
onChange={onChangeKnots}/>
<Form.Control type={"number"}
name={"k"}
value={settings.k}
min={5}
max={30}
onChange={onChangeKnots}/>
</Col>
</Row>
</Form.Group>
}
10 changes: 10 additions & 0 deletions src/hooks/useDebouncedEffect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useEffect } from "react";

export const useDebouncedEffect = (effect: () => void, deps: any[], delay: number) => {
useEffect(() => {
const handler = setTimeout(() => effect(), delay);

return () => clearTimeout(handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...(deps || []), delay]);
}
20 changes: 18 additions & 2 deletions src/reducers/datasetReducer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {AppState, DatasetSettings} from "../types";
import {AppState, DatasetSettings, SplineSettings} from "../types";
import {ActionType, RootAction} from "../RootContext";

export const datasetReducer = (state: AppState, action: RootAction): AppState => {
Expand All @@ -13,14 +13,23 @@ export const datasetReducer = (state: AppState, action: RootAction): AppState =>
return unselectCovariate(state, action)
case ActionType.SELECT_SCALE:
return selectScale(state, action)
case ActionType.SET_SPLINE_OPTIONS:
return setSpline(state, action)
default:
return state
}
}

const splineSettings = (): SplineSettings => ({
method: "auto",
span: 0.75,
k: 10
})

const datasetSettings = (): DatasetSettings => ({
covariateSettings: [],
scale: "natural"
scale: "natural",
splineSettings: splineSettings()
})

const selectDataset = (state: AppState, action: RootAction): AppState => {
Expand Down Expand Up @@ -53,3 +62,10 @@ const selectScale = (state: AppState, action: RootAction): AppState => {
newState.datasetSettings[state.selectedDataset].scale = action.payload
return newState
}

const setSpline = (state: AppState, action: RootAction): AppState => {
const newState = {...state}
const settings = newState.datasetSettings[state.selectedDataset].splineSettings;
newState.datasetSettings[state.selectedDataset].splineSettings = {...settings, ...action.payload}
return newState
}
5 changes: 5 additions & 0 deletions src/reducers/rootReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export const rootReducer = (state: AppState, action: RootAction): AppState => {
...state,
genericErrors: state.genericErrors.filter(e => e.detail !== action.payload.detail || e.error !== action.payload.error)
}
case ActionType.CLEAR_ALL_ERRORS:
return {
...state,
genericErrors: []
}
case ActionType.UPLOAD_ERROR_ADDED:
return {...state, uploadError: action.payload}
case ActionType.UPLOAD_ERROR_DISMISSED:
Expand Down
12 changes: 6 additions & 6 deletions src/services/apiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ export interface API<A> {

postAndReturn<T>(url: string, data: any): Promise<void | GenericResponse<T>>
get<T>(url: string): Promise<void | GenericResponse<T>>
delete(url: string): Promise<void | true>
}

export class APIService implements API<ActionType> {
Expand Down Expand Up @@ -120,6 +119,10 @@ export class APIService implements API<ActionType> {
this._dispatch({type: ActionType.ERROR_ADDED, payload: error});
};

private _clearErrors = () => {
this._dispatch({type: ActionType.CLEAR_ALL_ERRORS, payload: null});
};

private _verifyHandlers(url: string) {
if (this._onError == null && !this._ignoreErrors) {
console.warn(`No error handler registered for request ${url}.`)
Expand All @@ -131,6 +134,7 @@ export class APIService implements API<ActionType> {

async get<T>(url: string): Promise<void | GenericResponse<T>> {
this._verifyHandlers(url);
this._clearErrors();
const fullUrl = this._buildFullUrl(url);
return this._handleAxiosResponse(axios.get(fullUrl, {headers: this._headers}));
}
Expand All @@ -147,6 +151,7 @@ export class APIService implements API<ActionType> {

async postAndReturn<T>(url: string, data?: any): Promise<void | GenericResponse<T>> {
this._verifyHandlers(url);
this._clearErrors();
const fullUrl = this._buildFullUrl(url);

// this allows us to pass data of type FormData in both the browser and
Expand All @@ -158,11 +163,6 @@ export class APIService implements API<ActionType> {
return this._handleAxiosResponse(axios.post(fullUrl, data, {headers}));
}

async delete(url: string) {
const fullUrl = this._buildFullUrl(url);
return this._handleAxiosResponse(axios.delete(fullUrl));
}

}

export const api = (lang: string, dispatch: (action: RootAction) => void) => new APIService(lang, dispatch);
7 changes: 4 additions & 3 deletions src/services/dataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
UploadResult
} from "../generated";
import {
GenericResponse, CovariateSettings,
GenericResponse, CovariateSettings, SplineSettings,
} from "../types";
import {Dispatch} from "react";

Expand Down Expand Up @@ -51,7 +51,8 @@ export class DataService {
biomarker: string,
facetDefinition: string,
covariateSettings: CovariateSettings[],
scale: "log" | "natural" | "log2") {
scale: "log" | "natural" | "log2",
splineSettings: SplineSettings) {


const traces = covariateSettings
Expand All @@ -67,7 +68,7 @@ export class DataService {
queryString += `disaggregate=${encodeURIComponent(traces)}&`
}

queryString += `scale=${scale}`
queryString += `scale=${scale}&method=${splineSettings.method}&span=${splineSettings.span}&k=${splineSettings.k}`

return await this._api
.ignoreSuccess()
Expand Down
4 changes: 4 additions & 0 deletions src/services/plotUtils.ts → src/services/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ export const toFilename = (title: string) => title
.replaceAll(/\W+/g, " ")
.trim()
.replaceAll(/\s+/g, "_")

export const between = (x: number, min: number, max: number) => {
return Math.min(Math.max(x, min), max)
}
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,16 @@ export interface CovariateSettings extends Variable {
display: PlotDisplay
}

export interface SplineSettings {
method: "gam" | "loess" | "auto"
k: number
span: number
}

export interface DatasetSettings {
covariateSettings: CovariateSettings[]
scale: "log" | "natural" | "log2"
splineSettings: SplineSettings
}

export interface AppState {
Expand Down
21 changes: 12 additions & 9 deletions test/components/ChooseDataset.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ describe("<ChooseOrUploadDataset/>", () => {
</RootDispatchContext.Provider>
</RootContext.Provider>);

await waitFor(() => expect(dispatch.mock.calls.length).toBe(1));
await waitFor(() => expect(dispatch.mock.calls.length).toBe(2));

expect(dispatch.mock.calls[0][0]).toEqual({
expect(dispatch.mock.calls[1][0]).toEqual({
type: ActionType.DATASET_NAMES_FETCHED,
payload: ["d1", "d2"]
});
Expand Down Expand Up @@ -98,7 +98,7 @@ describe("<ChooseOrUploadDataset/>", () => {
const submit = screen.getByText("Go");
await user.click(submit);

expect(dispatch.mock.calls[1][0]).toEqual({
expect(dispatch.mock.calls[2][0]).toEqual({
type: ActionType.DATASET_SELECTED,
payload: "d2"
});
Expand Down Expand Up @@ -152,11 +152,14 @@ describe("<ChooseOrUploadDataset/>", () => {
const testFile = new File(['hello'], 'hello.csv', {type: 'text/csv'});
await user.upload(fileInput, testFile);

expect(dispatch.mock.calls.length).toBe(4);
expect(dispatch.mock.calls[0][0].type).toBe(ActionType.DATASET_NAMES_FETCHED);
expect(dispatch.mock.calls[1][0].type).toBe(ActionType.UPLOAD_ERROR_DISMISSED);
expect(dispatch.mock.calls[2][0].type).toBe(ActionType.DATASET_NAMES_FETCHED);
expect(dispatch.mock.calls[3][0].type).toBe(ActionType.DATASET_SELECTED);
expect(dispatch.mock.calls[3][0].payload).toBe("hello");
expect(dispatch.mock.calls.length).toBe(7);
expect(dispatch.mock.calls[0][0].type).toBe(ActionType.CLEAR_ALL_ERRORS);
expect(dispatch.mock.calls[1][0].type).toBe(ActionType.DATASET_NAMES_FETCHED);
expect(dispatch.mock.calls[2][0].type).toBe(ActionType.UPLOAD_ERROR_DISMISSED);
expect(dispatch.mock.calls[3][0].type).toBe(ActionType.CLEAR_ALL_ERRORS);
expect(dispatch.mock.calls[4][0].type).toBe(ActionType.CLEAR_ALL_ERRORS);
expect(dispatch.mock.calls[5][0].type).toBe(ActionType.DATASET_NAMES_FETCHED);
expect(dispatch.mock.calls[6][0].type).toBe(ActionType.DATASET_SELECTED);
expect(dispatch.mock.calls[6][0].payload).toBe("hello");
});
});
Loading

0 comments on commit 8c76677

Please sign in to comment.