diff --git a/app/RootContext.ts b/app/RootContext.ts index 130190d..703778d 100644 --- a/app/RootContext.ts +++ b/app/RootContext.ts @@ -1,18 +1,104 @@ import {createContext} from "react"; -export interface State { - history: string - titre_type: string +type PlotDisplay = "facet" | "trace" + +export interface PlotOptions { + [index: string]: PlotDisplay +} + +export interface PlotConfig { + key: string + displayName: string + type: string, + lineColors?: any + fillColors?: any +} + +export interface Model { + key: string + displayName: string + datasets: Dataset[] + regressionModels: Covariate[] + plots: PlotConfig[] + variables: Covariate[] +} + +export interface Dataset { + key: string + displayName: string +} + +export interface Covariate { + displayName: string + key: string +} + +export interface AppState { + models: Model[] + selectedModel: Model + selectedDataset: Dataset + selectedRegressionModel: Covariate + selectedPlotOptions: { [index: string]: PlotOptions } } -export interface ContextValue { - state: State +export interface AppContext { + state: AppState dispatch: () => void } -export const initialState = {history: "Facet", titre_type: "Trace"} -export const RootContext = createContext({state: initialState, dispatch: () => null}) +const biomarkerModel: Model = { + displayName: "Biomarker Kinetics", + key: "biomarker", + datasets: [{ + key: "legacy", + displayName: "SARS-CoV2-legacy" + }], + regressionModels: [ + {key: "infection_history", displayName: "Infection history"}, + {key: "last_exp_type", displayName: "Last exposure type"} + ], + plots: [{ + key: "pop_fits", + displayName: "Population fits", + type: "line", + lineColors: [ + `rgba(204, 102, 119, 1)`, + `rgba(221, 204, 119, 1)`, + `rgba(136, 204, 238, 1)`, + `rgba(136, 34, 85, 1)`, + `rgba(68, 170, 153, 1)`, + `rgba(226, 226, 226, 1)`] + , + fillColors: [ + `rgba(204, 102, 119, 0.3)`, + `rgba(221, 204, 119, 0.3)`, + `rgba(136, 204, 238, 0.3)`, + `rgba(136, 34, 85, 0.3)`, + `rgba(68, 170, 153, 0.3)`, + `rgba(226, 226, 226, 0.3)` + + ] + }], + variables: [{key: "titre_type", displayName: "Titre type"}] +} + +export const initialState: AppState = { + models: [biomarkerModel], + selectedModel: biomarkerModel, + selectedDataset: biomarkerModel.datasets[0], + selectedRegressionModel: biomarkerModel.regressionModels[0], + selectedPlotOptions: Object.fromEntries(biomarkerModel.plots.map(p => [p.key, + Object.fromEntries(biomarkerModel.variables.concat([biomarkerModel.regressionModels[0]]).map( + v => [v.key, "trace"] + )) + ])) +} + +export const RootContext = createContext({ + state: initialState, + dispatch: () => null +}) -export function rootReducer(oldState, newState): State { +export function rootReducer(oldState, newState): AppState { return {...newState} } diff --git a/app/components/ConfiguredPlot.tsx b/app/components/ConfiguredPlot.tsx new file mode 100644 index 0000000..7770056 --- /dev/null +++ b/app/components/ConfiguredPlot.tsx @@ -0,0 +1,93 @@ +import {useContext} from "react"; +import {AppContext, Covariate, PlotConfig, RootContext} from "~/RootContext"; +import {Col, Row} from "react-bootstrap"; +import LocalPlot from "~/components/LocalPlot"; + +interface Dat { + [index: string]: string | number +} + +interface Props { + data: Dat[], + facets: { [k: string]: string[] } + traces: { [k: string]: string[] } + covariate: Covariate + value: string, + facetVariables: Covariate[], + traceVariables: Covariate[], + plot: PlotConfig +} + +function Facet({ + data, + facets, + traces, + covariate, + value, + facetVariables, + traceVariables, + plot + }: Props) { + const filteredData = data.filter(d => d[covariate.key] == value); + const otherFacetVariables = facetVariables.filter(v => v.key != covariate.key); + const nextFacetVariable = otherFacetVariables.pop(); + if (!nextFacetVariable) { + return + } else { + + const facetValues = facets[nextFacetVariable.key]; + return facetValues.map(v => [
{value}
, ]) + } +} + +interface ConfigurePlotProps { + plot: PlotConfig + data: Dat[] +} +export default function ConfiguredPlot({plot, data}: ConfigurePlotProps) { + const {state} = useContext(RootContext); + const variables = [state.selectedRegressionModel].concat(state.selectedModel.variables); + const settings = state.selectedPlotOptions[plot.key]; + + const facetVariables = variables.filter(v => settings[v.key] == "facet"); + const traceVariables = variables.filter(v => settings[v.key] == "trace"); + + const facets = Object.fromEntries(facetVariables.map(v => [v.key, [...new Set(data.map(entry => entry[v.key]))]])); + const traces = Object.fromEntries(traceVariables.map(v => [v.key, [...new Set(data.map(entry => entry[v.key]))]])); + + if (facetVariables.length > 0) { + const firstFacet = facetVariables[0]; + const facetValues = facets[firstFacet.key]; + + return + {facetValues.map(v => )} + + } else { + return + } +} diff --git a/app/components/LineChart.tsx b/app/components/LineChart.tsx deleted file mode 100644 index a9126b3..0000000 --- a/app/components/LineChart.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { - Area, - CartesianGrid, - ComposedChart, - Legend, - Line, - XAxis, - YAxis, - Tooltip, ResponsiveContainer -} from "recharts"; -import React, {useContext, useEffect, useState} from "react"; -import {ContextValue, RootContext, State} from "../RootContext"; - -interface Dat { - t: number - me: number - lo: number - hi: number - titre_type: string - infection_history: string -} - -interface Props { - data: Dat[], - history: string - titre_type: string -} - -const colors = [{ - "ancestral": "#CC6677", - "alpha": "#DDCC77", - "delta": "#88CCEE" -}, { - "ancestral": "#882255", - "alpha": "#44AA99", - "delta": "#e2e2e2" -}] - -export const useIsServerSide = () => { - const [isServerSide, setIsServerSide] = useState(true); - - useEffect(() => { - setIsServerSide(false); - }, [setIsServerSide]); - - return isServerSide; -}; - -function legend(state: State, titre_type: string, history: string) { - let legendName = "median"; - if (state.titre_type == "Trace") { - legendName += " " + titre_type.toLowerCase() - } - if (state.history == "Trace") { - legendName += " " + history.toLowerCase() - } - return legendName -} - -export default function LineChart({data, history, titre_type}: Props) { - const {state} = useContext(RootContext); - const allData = data.filter(entry => - (state.history == "Trace" || entry.infection_history == history) && (state.titre_type == "Trace" || entry.titre_type == titre_type) - ).map(entry => ({ - ...entry, - CI: [entry.hi, entry.lo] - })); - - const titre_types = [...new Set(allData.map(entry => entry.titre_type))] - const histories = [...new Set(allData.map(entry => entry.infection_history))] - - const subsets = titre_types.flatMap(t => - histories.map(h => ({idx: [t, h], data: allData.filter(d => (d.titre_type == t && d.infection_history == h))}))) - const isServerSide = useIsServerSide(); - if (isServerSide) return null; - return [
{history} {titre_type}
, - - - - - { - subsets.map(({idx, data}, num) => [ - 0}>, - , - ]) - } - - - ] - -} diff --git a/app/components/LocalPlot.client.tsx b/app/components/LocalPlot.client.tsx new file mode 100644 index 0000000..709e35c --- /dev/null +++ b/app/components/LocalPlot.client.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import Plot from 'react-plotly.js'; +import {Covariate, PlotConfig} from "~/RootContext"; + +interface Dat { + t: number + me: number + lo: number + hi: number + [index: string]: string | number +} + +interface Props { + data: Dat[], + traceVariables: Covariate[] + traces: { [k: string]: string[] } + value: string + plot: PlotConfig +} + +function showLegend(traces: Covariate[]) { + return traces.length > 0 +} + +function permuteArrays(first, next, ...rest) { + if (!first) return []; + if (!next) next = [""]; + if (rest.length) next = permuteArrays(next, ...rest); + return first.flatMap(a => next.map(b => [a, b].flat())); +} + +export default function LocalPlot({data, traceVariables, traces, value, plot}: Props) { + + let traceDatasets = [data]; + const traceDefinitions = permuteArrays(...traceVariables.map(v => traces[v.key])) + + if (traceDefinitions.length > 0) { + traceDatasets = traceDefinitions.map(td => + data.filter(d => { + let include = true; + for (let i = 0; i < traceVariables.length; i++) { + if (d[traceVariables[i].key] != td[i]) { + include = false; + break; + } + } + return include + }) + ); + } + + const subsets = traceDatasets.map((dataset, i) => { + const times = dataset.map(d => d.t); + const seriesName = traceDefinitions.length > 0 ? traceDefinitions[i].join(" ") : ""; + return [{ + x: times, + y: dataset.map(d => d.lo), + name: seriesName, + line: {color: "transparent"}, + marker: {color: plot.lineColors[i]}, + showlegend: false, + type: "scatter", + mode: "lines" + }, { + y: dataset.map(d => d.me), + x: times, + name: seriesName, + type: 'scatter', + mode: 'lines', + fill: "tonexty", + fillcolor: plot.fillColors[i], + showlegend: showLegend(traceVariables), + marker: {color: plot.lineColors[i]}, + }, { + x: times, + y: dataset.map(d => d.hi), + name: seriesName, + line: {width: 0}, + showlegend: false, + type: "scatter", + mode: "lines", + fill: "tonexty", + fillcolor: plot.fillColors[i], + }] + }).flat(); + + return +} diff --git a/app/components/LocalPlot.tsx b/app/components/LocalPlot.tsx new file mode 100644 index 0000000..8ea86cb --- /dev/null +++ b/app/components/LocalPlot.tsx @@ -0,0 +1,21 @@ +import {ClientOnly} from "remix-utils/client-only" +import Plot from "./LocalPlot.client"; +import {Covariate, PlotConfig} from "~/RootContext"; + +interface Dat { + [index: string]: string | number +} + +interface Props { + data: Dat[], + traceVariables: Covariate[] + value: string + traces: { [k: string]: string[] } + plot: PlotConfig +} + +export default function LocalPlot(props: Props) { + return + {() => ()} + +} diff --git a/app/components/PlotForm.tsx b/app/components/PlotForm.tsx new file mode 100644 index 0000000..bd73dc5 --- /dev/null +++ b/app/components/PlotForm.tsx @@ -0,0 +1,49 @@ +import Form from "react-bootstrap/Form"; +import {Col, Row} from "react-bootstrap"; +import React, {ChangeEventHandler, ReactElement, useContext} from "react"; +import {AppContext, Covariate, RootContext} from "~/RootContext"; + +interface Props { + covariate: Covariate; + onSelect: ChangeEventHandler + selected: string +} + +const CovariateOptions = ({ covariate, onSelect, selected}: Props): ReactElement => { + return + + {covariate.displayName} + + + + + + + + +} + +export default function PlotForm({plot}): ReactElement[] { + + const {state, dispatch} = useContext(RootContext) + + function onSelect(e) { + const newState = {...state} + newState.selectedPlotOptions[plot.key][e.target.id] = e.target.value; + dispatch(newState); + } + + return [{plot.displayName}, + + + Choose how each variable is displayed + + {[state.selectedRegressionModel].concat(state.selectedModel.variables) + .map(o => )} + ,
] +} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index f566b9e..2cda531 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -1,14 +1,31 @@ import Form from 'react-bootstrap/Form'; -import {Col, Row} from "react-bootstrap"; +import {Col} from "react-bootstrap"; import React, {useContext} from "react"; -import {ContextValue, RootContext} from "../RootContext"; +import {AppContext, RootContext} from "../RootContext"; +import PlotForm from "./PlotForm"; export default function Sidebar() { - const {state, dispatch} = useContext(RootContext) - function onSelect(e) { + const {state, dispatch} = useContext(RootContext) + + function onSelectModel(e) { + const newState = {...state} + newState.selectedModel = state.models + .find(m => m.key == e.target.value) ?? state.selectedModel + dispatch(newState); + } + + function onSelectData(e) { + const newState = {...state} + newState.selectedDataset = state.selectedModel.datasets + .find(d => d.key == e.target.value) ?? state.selectedDataset + dispatch(newState); + } + + function onSelectCovariates(e) { const newState = {...state} - newState[e.target.id] = e.target.value + newState.selectedRegressionModel = state.selectedModel.regressionModels + .find(c => c.key == e.target.value) ?? state.selectedRegressionModel dispatch(newState); } @@ -16,60 +33,28 @@ export default function Sidebar() {
- Model - - - + Model + + {state.models.map(m => + )} Dataset - - + + {state.selectedModel.datasets.map(d => + )} - Hierarchical variables - - - - + Covariates + + {state.selectedModel.regressionModels.map(c => + )} -
- Population fits - - - Choose how each variable is displayed - - - - Titre type - - - - - - - - - - - Infection history - - - - - - - - - -
- - Peak titre values - + {state.selectedModel.plots.map(p => )}
diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 7fbd876..3afacab 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -1,22 +1,13 @@ -import {Col, Row} from "react-bootstrap"; - import {json} from "@remix-run/node"; // or cloudflare/deno -import {useLoaderData} from "@remix-run/react"; import {fs} from "../utils/fs-promises.server"; -import {dirname} from 'path'; -import {fileURLToPath} from 'url'; -import LineChart from "../components/LineChart"; -import {ContextValue, RootContext} from "../RootContext"; +import {AppContext, RootContext} from "../RootContext"; import {useContext} from "react"; - -// const __filename = fileURLToPath(import.meta.url); -// const __dirname = dirname(__filename); +import ConfiguredPlot from "~/components/ConfiguredPlot"; +import {useLoaderData} from "@remix-run/react"; export const loader = async () => { const jsonDirectory = "./data"; - // Read the json data file data.json - const fileContents = await fs.readFile(jsonDirectory + "/res.json", "utf8"); - // Parse the json data file contents into a json object + const fileContents = await fs.readFile([jsonDirectory, "legacy", "infection_history", "res.json"].join("/"), "utf8"); const data = JSON.parse(fileContents); return json( @@ -25,36 +16,7 @@ export const loader = async () => { }; export default function Index() { + const {state} = useContext(RootContext); const data = useLoaderData(); - const {state} = useContext(RootContext); - - const titre_types = [...new Set(data.map(entry => entry.titre_type))] - const histories = [...new Set(data.map(entry => entry.infection_history))] - - if (state.history == "Facet") { - return {histories.map((h, hidx) => { - if (state.titre_type == "Facet") { - return titre_types.map((t, tidx) => - - - ) - } else { - return - } - })} - } else if (state.titre_type == "Facet") { - return { - titre_types.map((t, tidx) => - - )} - - } else { - return - - - - - } + return state.selectedModel.plots.map(p => ) } diff --git a/data/res.json b/data/legacy/infection_history/res.json similarity index 100% rename from data/res.json rename to data/legacy/infection_history/res.json diff --git a/eslint.config.js b/eslint.config.mjs similarity index 77% rename from eslint.config.js rename to eslint.config.mjs index 208e978..a36c388 100644 --- a/eslint.config.js +++ b/eslint.config.mjs @@ -6,7 +6,13 @@ import pluginReactConfig from "eslint-plugin-react/configs/recommended.js"; export default [ {files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"]}, - {languageOptions: {parserOptions: {ecmaFeatures: {jsx: true}}}}, + { + settings: { + react: { + version: "detect", + }, + }, languageOptions: {parserOptions: {ecmaFeatures: {jsx: true}}} + }, {files: ["**/*.js"], languageOptions: {sourceType: "script"}}, {languageOptions: {globals: globals.browser}}, pluginJs.configs.recommended, diff --git a/package-lock.json b/package-lock.json index 8e5844f..049ca23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "express": "^4.19.2", "isbot": "^4.4.0", "lucide-react": "^0.397.0", - "plotly.js": "^2.33.0", + "plotly.js": "^2.34.0", "react": "^18.3.1", "react-bootstrap": "^2.10.3", "react-dom": "^18.3.1", @@ -10986,9 +10986,9 @@ } }, "node_modules/plotly.js": { - "version": "2.33.0", - "resolved": "https://registry.npmjs.org/plotly.js/-/plotly.js-2.33.0.tgz", - "integrity": "sha512-pzuf6hSUCaSYmEag2b2DngkHdYMn+U/QMSC/UJOLIS8yd2UwIG1iGUmOR7pqZIS87oKx/+cMoG8aknGytgJKig==", + "version": "2.34.0", + "resolved": "https://registry.npmjs.org/plotly.js/-/plotly.js-2.34.0.tgz", + "integrity": "sha512-dG2LC6wY6AUR1jsnriBi9xbigLPEEXXOHhLo97dRiZAWZVS6lZCmXXZ227U4rsoluXyfyqQezaKq7svolap8Dw==", "dependencies": { "@plotly/d3": "3.8.2", "@plotly/d3-sankey": "0.7.2", diff --git a/package.json b/package.json index d3c275e..bb82529 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "express": "^4.19.2", "isbot": "^4.4.0", "lucide-react": "^0.397.0", - "plotly.js": "^2.33.0", + "plotly.js": "^2.34.0", "react": "^18.3.1", "react-bootstrap": "^2.10.3", "react-dom": "^18.3.1", diff --git a/remix.config.js b/remix.config.js index 48fa19f..f49debf 100644 --- a/remix.config.js +++ b/remix.config.js @@ -1,5 +1,8 @@ /** @type {import("@remix-run/dev").AppConfig} */ module.exports = { serverModuleFormat: "cjs", - browserNodeBuiltinsPolyfill: { modules: { url: true, path: true } } + browserNodeBuiltinsPolyfill: { modules: { url: true, path: true } }, + serverDependenciesToBundle: [ + /remix-utils/ // not "remix-utils" + ], }