diff --git a/src/App.tsx b/src/App.tsx index 8daaca0..4a6c961 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,11 +31,7 @@ import RightPanel from "./components/main-layout/RightPanel"; import ReactionDisplay from "./components/main-layout/ReactionDisplay"; import ContentPanelTimer from "./components/main-layout/ContentPanelTimer"; import content, { FIRST_PAGE, moduleNames } from "./content"; -import { - PROMPT_TO_ADJUST_B, - DEFAULT_VIEWPORT_SIZE, - LIVE_SIMULATION_NAME, -} from "./constants"; +import { DEFAULT_VIEWPORT_SIZE, LIVE_SIMULATION_NAME } from "./constants"; import CenterPanel from "./components/main-layout/CenterPanel"; import { SimulariumContext } from "./simulation/context"; import NavPanel from "./components/main-layout/NavPanel"; @@ -88,9 +84,13 @@ function App() { const [inputConcentration, setInputConcentration] = useState({ [AgentName.A]: - LiveSimulationData.INITIAL_CONCENTRATIONS[AgentName.A], + LiveSimulationData.INITIAL_CONCENTRATIONS[Module.A_B_AB][ + AgentName.A + ], [AgentName.B]: - LiveSimulationData.INITIAL_CONCENTRATIONS[AgentName.B], + LiveSimulationData.INITIAL_CONCENTRATIONS[Module.A_B_AB][ + AgentName.B + ], }); const [timeFactor, setTimeFactor] = useState( LiveSimulationData.INITIAL_TIME_FACTOR @@ -107,9 +107,13 @@ function App() { const [liveConcentration, setLiveConcentration] = useState({ [AgentName.A]: - LiveSimulationData.INITIAL_CONCENTRATIONS[AgentName.A], + LiveSimulationData.INITIAL_CONCENTRATIONS[Module.A_B_AB][ + AgentName.A + ], [AgentName.B]: - LiveSimulationData.INITIAL_CONCENTRATIONS[AgentName.B], + LiveSimulationData.INITIAL_CONCENTRATIONS[Module.A_B_AB][ + AgentName.B + ], [productName]: 0, }); const [recordedInputConcentration, setRecordedInputConcentration] = @@ -141,11 +145,11 @@ function App() { setCurrentProductConcentrationArray, ] = useState([]); - const resetCurrentRunAnalysisState = () => { + const resetCurrentRunAnalysisState = useCallback(() => { setBindingEventsOverTime([]); setUnBindingEventsOverTime([]); setCurrentProductConcentrationArray([]); - }; + }, []); const clearAllAnalysisState = useCallback(() => { resetCurrentRunAnalysisState(); @@ -154,7 +158,7 @@ function App() { setRecordedReactantConcentration([]); setTimeToReachEquilibrium([]); setDataColors([]); - }, []); + }, [resetCurrentRunAnalysisState]); const isPassedEquilibrium = useRef(false); const arrayLength = currentProductConcentrationArray.length; @@ -176,26 +180,42 @@ function App() { return new SimulariumController({}); }, []); + const sectionType = content[currentModule][page].section; + const clientSimulator = useMemo(() => { const activeAgents = simulationData.getActiveAgents(currentModule); setInputConcentration( - simulationData.getInitialConcentrations(activeAgents) + simulationData.getInitialConcentrations( + activeAgents, + currentModule, + sectionType === Section.Experiment + ) ); resetCurrentRunAnalysisState(); - const trajectory = - simulationData.createAgentsFromConcentrations(activeAgents); + const trajectory = simulationData.createAgentsFromConcentrations( + activeAgents, + currentModule, + sectionType === Section.Experiment + ); if (!trajectory) { return null; } const longestAxis = Math.max(viewportSize.width, viewportSize.height); const productColor = simulationData.getAgentColor(productName); - return new BindingSimulator(trajectory, longestAxis / 3, productColor); + const startMixed = sectionType !== Section.Introduction; + return new BindingSimulator( + trajectory, + longestAxis / 3, + productColor, + startMixed ? "random" : "sorted" + ); }, [ simulationData, currentModule, + resetCurrentRunAnalysisState, viewportSize.width, viewportSize.height, - productName, + sectionType, ]); const preComputedPlotDataManager = useMemo(() => { @@ -295,11 +315,6 @@ function App() { const handleNewInputConcentration = useCallback( (name: string, value: number) => { - if (value === 0) { - // this is available on the slider, but we only want it visible - // as an axis marker, not as a selection - return; - } if (!clientSimulator) { return; } @@ -309,7 +324,11 @@ function App() { const agentName = name as keyof typeof LiveSimulationData.AVAILABLE_AGENTS; const agentId = LiveSimulationData.AVAILABLE_AGENTS[agentName].id; - clientSimulator.changeConcentration(agentId, value); + clientSimulator.changeConcentration( + agentId, + value, + sectionType === Section.Experiment ? "random" : "sorted" + ); simulariumController.gotoTime(1); // the number isn't used, but it triggers the update const previousConcentration = inputConcentration[agentName] || 0; addProductionTrace(previousConcentration); @@ -321,26 +340,29 @@ function App() { inputConcentration, addProductionTrace, resetCurrentRunAnalysisState, + sectionType, ] ); + const totalReset = useCallback(() => { + const activeAgents = [AgentName.A, AgentName.B]; + setCurrentModule(Module.A_B_AB); + const concentrations = simulationData.getInitialConcentrations( + activeAgents, + Module.A_B_AB + ); setLiveConcentration({ - [AgentName.A]: - LiveSimulationData.INITIAL_CONCENTRATIONS[AgentName.A], - [AgentName.B]: - LiveSimulationData.INITIAL_CONCENTRATIONS[AgentName.B], + [AgentName.A]: concentrations[AgentName.A], + [AgentName.B]: concentrations[AgentName.B], [productName]: 0, }); - setCurrentModule(Module.A_B_AB); setInputConcentration({ - [AgentName.A]: - LiveSimulationData.INITIAL_CONCENTRATIONS[AgentName.A], - [AgentName.B]: - LiveSimulationData.INITIAL_CONCENTRATIONS[AgentName.B], + [AgentName.A]: concentrations[AgentName.A], + [AgentName.B]: concentrations[AgentName.B], }); handleNewInputConcentration( adjustableAgentName, - LiveSimulationData.INITIAL_CONCENTRATIONS[AgentName.B] + concentrations[AgentName.B] ?? 4 ); setIsPlaying(false); clearAllAnalysisState(); @@ -350,6 +372,7 @@ function App() { handleNewInputConcentration, productName, adjustableAgentName, + simulationData, ]); // Special events in page navigation // usePageNumber takes a page number, a conditional and a callback @@ -369,18 +392,19 @@ function App() { totalReset(); } ); - + const hasRecordedFirstValue = useRef(false); // they have recorded a single value, changed the slider and pressed play usePageNumber( page, - (page) => + () => currentModule === Module.A_B_AB && - page === PROMPT_TO_ADJUST_B && + !hasRecordedFirstValue.current && isPlaying && - recordedInputConcentration.length > 0 && + recordedInputConcentration.length === 1 && recordedInputConcentration[0] !== inputConcentration[adjustableAgentName], () => { + hasRecordedFirstValue.current = true; setPage(page + 1); } ); @@ -457,13 +481,37 @@ function App() { const setModule = (module: Module) => { setPage(FIRST_PAGE[module]); + clearAllAnalysisState(); setCurrentModule(module); setIsPlaying(false); }; + const setExperiment = () => { + setIsPlaying(false); + + const activeAgents = simulationData.getActiveAgents(currentModule); + const concentrations = simulationData.getInitialConcentrations( + activeAgents, + currentModule, + true + ); + clientSimulator?.mixAgents(); + setTimeFactor(LiveSimulationData.INITIAL_TIME_FACTOR); + setInputConcentration(concentrations); + setLiveConcentration(concentrations); + }; + + const handleMixAgents = useCallback(() => { + if (clientSimulator) { + setIsPlaying(false); + clientSimulator.mixAgents(); + simulariumController.gotoTime(1); + } + }, [clientSimulator, simulariumController]); + const handleStartExperiment = () => { - simulariumController.pause(); - totalReset(); + clearAllAnalysisState(); + setExperiment(); setPage(page + 1); }; @@ -628,48 +676,48 @@ function App() {
- } - landingPage={ - } content={ @@ -690,8 +738,18 @@ function App() { currentModule={currentModule} /> } - reactionPanel={ - + header={ + + } + landingPage={ + } leftPanel={ } - centerPanel={ - + reactionPanel={ + } rightPanel={ } + section={content[currentModule][page].section} + layout={content[currentModule][page].layout} /> = ({ totalPages, }) => { const { page, setPage, module, setModule } = useContext(SimulariumContext); - const isDev = process.env.NODE_ENV === "development"; - const [visible, setVisible] = React.useState(isDev); + const [visible, setVisible] = React.useState(false); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // control-option-1 (mac) or ctrl-alt-1 (windows) @@ -40,7 +39,7 @@ const AdminUI: React.FC = ({ pageMarks[i] = { label: i.toString() }; } const moduleMarks: SliderSingleProps["marks"] = {}; - const totalNumberOfModules = 2; // only 2 modules that work currently + const totalNumberOfModules = 3; for (let i = 0; i <= totalNumberOfModules; i++) { moduleMarks[i] = { label: i.toString() }; } diff --git a/src/components/LandingPage.tsx b/src/components/LandingPage.tsx index 532bae6..9a97494 100644 --- a/src/components/LandingPage.tsx +++ b/src/components/LandingPage.tsx @@ -12,7 +12,7 @@ interface LandingPageProps extends PageContent { const MODULE_CLASSNAMES: Record = { [Module.A_B_AB]: styles.highAffinity, [Module.A_C_AC]: styles.lowAffinity, - [Module.A_B_C_AB_AC]: styles.competitive, + [Module.A_B_D_AB]: styles.competitive, }; const LandingPage: React.FC = ({ diff --git a/src/components/MixButton.tsx b/src/components/MixButton.tsx new file mode 100644 index 0000000..430448d --- /dev/null +++ b/src/components/MixButton.tsx @@ -0,0 +1,26 @@ +import React, { useContext } from "react"; + +import { SimulariumContext } from "../simulation/context"; +import { TertiaryButton } from "./shared/ButtonLibrary"; +import { MIX_AGENTS_ID } from "../constants"; +import ProgressionControl from "./shared/ProgressionControl"; +import style from "./start-experiment.module.css"; + +const MixButton: React.FC = () => { + const { handleMixAgents } = useContext(SimulariumContext); + + return ( + + + Randomize positions + + + ); +}; + +export default MixButton; diff --git a/src/components/PageIndicator.tsx b/src/components/PageIndicator.tsx index ab11b8c..52d9d0c 100644 --- a/src/components/PageIndicator.tsx +++ b/src/components/PageIndicator.tsx @@ -5,6 +5,7 @@ import classNames from "classnames"; import { moduleNames } from "../content"; import styles from "./page-indicator.module.css"; +import { SimulariumContext } from "../simulation/context"; interface PageIndicatorProps { title: string; @@ -18,6 +19,7 @@ const PageIndicator: React.FC = ({ total, }) => { let indexOfActiveModule = -1; + const { setModule } = React.useContext(SimulariumContext); const getModulePercent = (isActiveModule: boolean, moduleIndex: number) => { if (isActiveModule) { @@ -44,6 +46,12 @@ const PageIndicator: React.FC = ({ moduleIndex <= indexOfActiveModule, [styles.current]: isActiveModule, })} + onClick={() => { + if (isActiveModule) { + return; + } + setModule(moduleIndex); + }} >
{name}
{ const { isPlaying, setIsPlaying } = useContext(SimulariumContext); @@ -18,11 +19,7 @@ const PlayButton: React.FC = () => { return ( - + void; } -const FIRST_RECORD_PAGE = 7; -const SECOND_RECORD_PAGE = 9; + const RecordEquilibriumButton = ({ handleRecordEquilibrium, }: RecordEquilibriumButtonProps) => { return ( - + Record ); diff --git a/src/components/ViewSwitch.tsx b/src/components/ViewSwitch.tsx index d33ed56..b68ddef 100644 --- a/src/components/ViewSwitch.tsx +++ b/src/components/ViewSwitch.tsx @@ -12,6 +12,7 @@ import VisibilityControl from "./shared/VisibilityControl"; import { Module, Section } from "../types"; import { FIRST_PAGE } from "../content"; import useModule from "../hooks/useModule"; +import { VIEW_SWITCH_ID } from "../constants"; enum View { Lab = "lab", @@ -70,7 +71,7 @@ const ViewSwitch: React.FC = () => { return (
- + { @@ -19,6 +21,10 @@ export const C: React.FC = () => { return C; }; +export const D: React.FC = () => { + return D; +}; + export const AB: React.FC<{ name?: string }> = (props) => { return {props.name || "AB"}; }; @@ -26,3 +32,7 @@ export const AB: React.FC<{ name?: string }> = (props) => { export const AC: React.FC = () => { return AC; }; + +export const AD: React.FC = () => { + return AD; +}; diff --git a/src/components/concentration-display/Concentration.tsx b/src/components/concentration-display/Concentration.tsx index 55dec21..b288a24 100644 --- a/src/components/concentration-display/Concentration.tsx +++ b/src/components/concentration-display/Concentration.tsx @@ -13,7 +13,7 @@ import { import { SimulariumContext } from "../../simulation/context"; import LiveConcentrationDisplay from "./LiveConcentrationDisplay"; import ConcentrationSlider from "./ConcentrationSlider"; -import { PROMPT_TO_ADJUST_B, MICRO } from "../../constants"; +import { MICRO, CHANGE_CONCENTRATION_ID } from "../../constants"; import ResizeContainer from "../shared/ResizeContainer"; import glowStyle from "../shared/progression-control.module.css"; import InfoText from "../shared/InfoText"; @@ -42,8 +42,13 @@ const Concentration: React.FC = ({ liveConcentration, onChangeComplete, }) => { - const { isPlaying, maxConcentration, page, getAgentColor, section } = - useContext(SimulariumContext); + const { + isPlaying, + maxConcentration, + getAgentColor, + section, + progressionElement, + } = useContext(SimulariumContext); const [width, setWidth] = useState(0); const MARGINS = 64.2; @@ -54,7 +59,7 @@ const Concentration: React.FC = ({ ); if ( - page === PROMPT_TO_ADJUST_B && + CHANGE_CONCENTRATION_ID === progressionElement && !isPlaying && highlightState === HighlightState.Initial ) { @@ -128,7 +133,7 @@ const Concentration: React.FC = ({ const showHighlight = highlightState === HighlightState.Show && - page === PROMPT_TO_ADJUST_B && + CHANGE_CONCENTRATION_ID === progressionElement && !isPlaying; return ( <> diff --git a/src/components/concentration-display/ConcentrationSlider.tsx b/src/components/concentration-display/ConcentrationSlider.tsx index f978d23..2093aed 100644 --- a/src/components/concentration-display/ConcentrationSlider.tsx +++ b/src/components/concentration-display/ConcentrationSlider.tsx @@ -69,7 +69,11 @@ const ConcentrationSlider: React.FC = ({ marks[index] = { label: ( 0 + ? (index.toFixed(1) as unknown as number) + : index + } disabledNumbers={disabledNumbers} onMouseUp={() => onChangeComplete?.(name, index)} /> diff --git a/src/components/concentration-display/LiveConcentrationDisplay.tsx b/src/components/concentration-display/LiveConcentrationDisplay.tsx index 235c5b0..4d674d9 100644 --- a/src/components/concentration-display/LiveConcentrationDisplay.tsx +++ b/src/components/concentration-display/LiveConcentrationDisplay.tsx @@ -19,7 +19,7 @@ const LiveConcentrationDisplay: React.FC = ({ const { maxConcentration, getAgentColor } = useContext(SimulariumContext); // the steps have a 2px gap, so we are adjusting the // size of the step based on the total number we want - const steps = Math.min(maxConcentration, 10); + const steps = Math.max(maxConcentration, 10); const size = width / steps - 2; return (
diff --git a/src/components/icons/ReversibleArrows.tsx b/src/components/icons/ReversibleArrows.tsx index c4c31e2..47e5d13 100644 --- a/src/components/icons/ReversibleArrows.tsx +++ b/src/components/icons/ReversibleArrows.tsx @@ -1,6 +1,10 @@ import React from "react"; -const ReversibleArrows: React.FC = () => { +interface ReversibleArrowsProps { + reverse?: boolean; +} + +const ReversibleArrows: React.FC = ({ reverse }) => { return ( { d="M48 14L58.75 20.9875H3.75" stroke="#D3D3D3" strokeWidth="1.25" + style={{ + transform: reverse + ? "scaleX(-1) translateX(-100%)" + : "none", + }} /> { + return ( + + + + + + + ); +}; + +export default ReversibleArrows2; diff --git a/src/components/landing-page.module.css b/src/components/landing-page.module.css index e907e1b..d9cf563 100644 --- a/src/components/landing-page.module.css +++ b/src/components/landing-page.module.css @@ -9,6 +9,10 @@ background-image: url("../assets/poliovirus-neutralization.jpg"); } +.container.competitive { + background-image: url("../assets/sars-cov-2-and-neutralizing-antibodies.jpg"); +} + .content { background-color: rgba(0, 0, 0, 0.8); position: absolute; diff --git a/src/components/main-layout/ContentPanel.tsx b/src/components/main-layout/ContentPanel.tsx index 7071f2f..76c4065 100644 --- a/src/components/main-layout/ContentPanel.tsx +++ b/src/components/main-layout/ContentPanel.tsx @@ -56,15 +56,17 @@ const ContentPanel: React.FC = ({

{header}

{content}

- {modal && ( - + + + {modal && ( - {moreInfo && {moreInfo}} - - )} + )} + {moreInfo && {moreInfo}} + + {callToAction && (

{callToAction} diff --git a/src/components/main-layout/LeftPanel.tsx b/src/components/main-layout/LeftPanel.tsx index 41e67da..ba6ddf8 100644 --- a/src/components/main-layout/LeftPanel.tsx +++ b/src/components/main-layout/LeftPanel.tsx @@ -32,13 +32,13 @@ const LeftPanel: React.FC = ({ const concentrationExcludedPages = { [Module.A_B_AB]: [0, 1], [Module.A_C_AC]: [], - [Module.A_B_C_AB_AC]: [], + [Module.A_B_D_AB]: [], }; const eventsOverTimeExcludedPages = { [Module.A_B_AB]: [0, 1, 2], [Module.A_C_AC]: [], - [Module.A_B_C_AB_AC]: [], + [Module.A_B_D_AB]: [0, 1, 2, 3, 4], }; return ( <> diff --git a/src/components/main-layout/ReactionDisplay.tsx b/src/components/main-layout/ReactionDisplay.tsx index eced843..d692925 100644 --- a/src/components/main-layout/ReactionDisplay.tsx +++ b/src/components/main-layout/ReactionDisplay.tsx @@ -1,9 +1,12 @@ import React from "react"; import ReversibleArrows from "../icons/ReversibleArrows"; import styles from "./layout.module.css"; -import { A, B, C, AC, AB } from "../agent-symbols"; +import { A, B, C, AC, AB, D, AD } from "../agent-symbols"; import { Module, UiElement } from "../../types"; import InfoText from "../shared/InfoText"; +import ReversibleArrows2 from "../icons/ReversibleArrows2"; +import { Divider } from "antd"; +import { LIGHT_GREY } from "../../constants/colors"; interface ReactionDisplayProps { reactionType: Module; @@ -30,16 +33,24 @@ const ReactionDisplay: React.FC = ({ reactionType }) => { )} - {reactionType === Module.A_B_C_AB_AC && ( + {reactionType === Module.A_B_D_AB && ( <> - - + + + + - - - - + + + + + + + + )} diff --git a/src/components/page-indicator.module.css b/src/components/page-indicator.module.css index 4601adc..97831c2 100644 --- a/src/components/page-indicator.module.css +++ b/src/components/page-indicator.module.css @@ -27,10 +27,23 @@ position: absolute; width: 10px; height: 10px; - top: 23px; /* aligned by eye after rest of layout created */ - left: -2px; + top: 24px; /* aligned by eye after rest of layout created */ + left: 0px; border-radius: 50%; background-color: var(--dark-gray); + cursor: pointer; +} + +/* click target */ +.progress-bar-wrapper::after { + content: ""; + position: absolute; + width: 20px; + height: 20px; + top: 20px; + left: -5px; + border-radius: 50%; + cursor: pointer; } .progress-bar { diff --git a/src/components/plots/EquilibriumPlot.tsx b/src/components/plots/EquilibriumPlot.tsx index cb7c28e..9d77339 100644 --- a/src/components/plots/EquilibriumPlot.tsx +++ b/src/components/plots/EquilibriumPlot.tsx @@ -14,6 +14,7 @@ import { MICRO } from "../../constants"; import plotStyles from "./plots.module.css"; import { Dash } from "plotly.js"; +import { Module } from "../../types"; interface PlotProps { x: number[]; @@ -37,26 +38,42 @@ const EquilibriumPlot: React.FC = ({ productName, getAgentColor, adjustableAgentName, + module, } = useContext(SimulariumContext); const xMax = Math.max(...x); const xAxisMax = Math.max(kd * 2, xMax * 1.1); // Calculate the best fit line for the data points const bestFit = useMemo(() => { - const regressionData: DataPoint[] = x.map( - (xVal, index) => [xVal, y[index]] -); + const regressionData: DataPoint[] = x.map((xVal, index) => [ + xVal, + y[index], + ]); + let bestFit; - const bestFit = regression.logarithmic(regressionData); + let value; + if (module === Module.A_B_D_AB) { + bestFit = regression.exponential(regressionData); + const max = Math.max(...y); + const min = 0; + const valueAtHalfMax = (max - min) / 2 + min; + value = + (1 / bestFit.equation[1]) * + Math.log(valueAtHalfMax / bestFit.equation[0]); + } else { + bestFit = regression.logarithmic(regressionData); + + const halfFilled = fixedAgentStartingConcentration / 2; + value = + Math.E ** + ((halfFilled - bestFit.equation[0]) / bestFit.equation[1]); + } const bestFitPoints = bestFit.points; + const bestFitX = bestFitPoints.map((point) => point[0]); const bestFitY = bestFitPoints.map((point) => point[1]); - const halfFilled = fixedAgentStartingConcentration / 2; - const kdValue = - Math.E ** - ((halfFilled - bestFit.equation[0]) / bestFit.equation[1]); - return { x: bestFitX, y: bestFitY, kd: kdValue }; - }, [x, y, fixedAgentStartingConcentration]); + return { x: bestFitX, y: bestFitY, value: value }; + }, [x, y, fixedAgentStartingConcentration, module]); const hintOverlay = (

= ({ const horizontalLine = { x: [0, xAxisMax], - y: [5, 5], + y: [2.5, 2.5], mode: "lines", name: "50% bound", hovertemplate: "50% bound", @@ -95,7 +112,7 @@ const EquilibriumPlot: React.FC = ({ }; const horizontalLineMax = { x: [0, xAxisMax], - y: [10, 10], + y: [5, 5], mode: "lines", name: "Initial [A]", hoverlabel: { bgcolor: AGENT_A_COLOR }, @@ -104,11 +121,11 @@ const EquilibriumPlot: React.FC = ({ }; const kdIndicator = { - x: [bestFit.kd, bestFit.kd], + x: [bestFit.value, bestFit.value], y: [0, fixedAgentStartingConcentration / 2], mode: "lines", name: "", - hovertemplate: `Kd: ${bestFit.kd.toFixed(2)} ${MICRO}M`, + hovertemplate: `Kd: ${bestFit.value.toFixed(2)} ${MICRO}M`, hoverlabel: { bgcolor: getAgentColor(adjustableAgentName), }, @@ -145,14 +162,18 @@ const EquilibriumPlot: React.FC = ({ ]; let xAxisTicks = []; - for (let i = 0; i <= xAxisMax; i = i + 0.5) { + const interval = xAxisMax > 50 ? 50 : 0.5; + for (let i = 0; i <= xAxisMax; i = i + interval) { xAxisTicks.push(i); } - if (x.length >= 3) { + // the best fit line will only be show if there are 3 or more points + const bestFitVisible = x.length >= 3; + if (bestFitVisible) { + // add the kd indicator line traces.push(kdIndicator); - // filter out values that are so close to the kd value that they would overlap + // filter out axis values that are so close to the kd value that they would overlap on the axis xAxisTicks = xAxisTicks.filter( - (tick) => Math.abs(tick - bestFit.kd) >= 0.2 + (tick) => Math.abs(tick - bestFit.value) >= interval / 2 ); } @@ -169,8 +190,8 @@ const EquilibriumPlot: React.FC = ({ ...AXIS_SETTINGS.titlefont, color: getAgentColor(adjustableAgentName), }, - tickmode: x.length >= 3 ? ("array" as const) : ("auto" as const), - tickvals: [...xAxisTicks, bestFit.kd.toFixed(1)], + tickmode: bestFitVisible ? ("array" as const) : ("auto" as const), + tickvals: [...xAxisTicks, bestFit.value.toFixed(1)], }, yaxis: { ...AXIS_SETTINGS, diff --git a/src/components/quiz-questions/EquilibriumQuestion.tsx b/src/components/quiz-questions/EquilibriumQuestion.tsx index 4fdaa4b..6cafa99 100644 --- a/src/components/quiz-questions/EquilibriumQuestion.tsx +++ b/src/components/quiz-questions/EquilibriumQuestion.tsx @@ -1,16 +1,28 @@ -import React, { useContext, useState } from "react"; +import React, { useContext, useRef, useState } from "react"; import QuizForm from "./QuizForm"; import VisibilityControl from "../shared/VisibilityControl"; import { FormState } from "./types"; import RadioComponent from "../shared/Radio"; -import { PROMPT_TO_ADJUST_B } from "../../constants"; +import { EQUILIBRIUM_QUIZ_ID } from "../../constants"; import { SimulariumContext } from "../../simulation/context"; +import { Module } from "../../types"; const EquilibriumQuestion: React.FC = () => { - const { page } = useContext(SimulariumContext); + const id = EQUILIBRIUM_QUIZ_ID; + const { page, quizQuestion, module } = useContext(SimulariumContext); const [selectedAnswer, setSelectedAnswer] = useState(""); const [formState, setFormState] = useState(FormState.Clear); + const firstVisiblePage = useRef<{ page: number; module: Module }>({ + page: Infinity, + module: Module.A_B_AB, + }); + const hasBeenInitialized = firstVisiblePage.current.page !== Infinity; + // quiz questions have a start page, but they can continue to be visible + // throughout the module, so we need to track the first visible page + if (quizQuestion === id && !hasBeenInitialized) { + firstVisiblePage.current = { page, module: module }; + } const handleAnswerSelection = (answer: string) => { setSelectedAnswer(answer); @@ -63,10 +75,13 @@ const EquilibriumQuestion: React.FC = () => { ); // Hide the question if the user has already answered correctly, and moved on // to the next page - const hide = page > PROMPT_TO_ADJUST_B && formState === FormState.Correct; + const hide = + page > firstVisiblePage.current?.page && + formState === FormState.Correct; + return ( diff --git a/src/components/quiz-questions/KdQuestion.tsx b/src/components/quiz-questions/KdQuestion.tsx index 1970336..baaa51e 100644 --- a/src/components/quiz-questions/KdQuestion.tsx +++ b/src/components/quiz-questions/KdQuestion.tsx @@ -93,6 +93,10 @@ const KdQuestion: React.FC = ({ kd, canAnswer }) => { You have now measured enough points to estimate the value of B where half of the binding sites of A are occupied.

+

+ If you're not sure, look at the where the line crosses the 50% + mark on the Equilibrium concentration plot. +

Kd = ? diff --git a/src/components/shared/ProgressionControl.tsx b/src/components/shared/ProgressionControl.tsx index e775961..6216a3d 100644 --- a/src/components/shared/ProgressionControl.tsx +++ b/src/components/shared/ProgressionControl.tsx @@ -1,8 +1,9 @@ import React, { useContext } from "react"; import { SimulariumContext } from "../../simulation/context"; -import { BaseHandler, Module, ProgressionControlEvent } from "../../types"; +import { BaseHandler, ProgressionControlEvent } from "../../types"; import styles from "./progression-control.module.css"; +import { ProgressionElement } from "../../constants"; type ProgressionControlChildProps = | React.InputHTMLAttributes @@ -11,7 +12,7 @@ type ProgressionControlChildProps = type ProgressionControlChild = React.ReactElement; interface ProgressionControlProps { children: ProgressionControlChild; - onPage: { [key in Module]?: number[] }; + elementId: ProgressionElement; } /** @@ -21,17 +22,17 @@ interface ProgressionControlProps { */ const ProgressionControl: React.FC = ({ children, - onPage, + elementId, }) => { - const { page, setPage, module } = useContext(SimulariumContext); - const pagesToAdvance = onPage[module]; + const { page, setPage, progressionElement } = useContext(SimulariumContext); + const shouldProgress = progressionElement === elementId; const progress = () => { - if (pagesToAdvance?.includes(page)) { + if (shouldProgress) { setPage(page + 1); } }; - const showHighlight = pagesToAdvance?.includes(page); + const showHighlight = shouldProgress; const mergeHandlers = (baseHandler: BaseHandler) => { return ( diff --git a/src/constants/colors.ts b/src/constants/colors.ts index dd3d3f5..5557c1e 100644 --- a/src/constants/colors.ts +++ b/src/constants/colors.ts @@ -1,8 +1,10 @@ export const AGENT_A_COLOR = "#4DFEFE"; // cyan export const AGENT_B_COLOR = "#FE4DC2"; // magenta export const AGENT_C_COLOR = "#FEAD4D"; // orange +export const AGENT_D_COLOR = "#694DFF"; // purple export const AGENT_AB_COLOR = "#FFEB00"; // yellow export const AGENT_AC_COLOR = "#70FE4D"; // green +export const AGENT_AD_COLOR = "#686868"; // gray export const MID_GREY = "#6E6E6E"; export const LIGHT_GREY_PURPLE = "#9E6AFF"; diff --git a/src/constants/index.ts b/src/constants/index.ts index a2f00e2..43ab2c0 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -2,4 +2,21 @@ export const MICRO = String.fromCharCode(956); export const NANO = "n"; export const DEFAULT_VIEWPORT_SIZE = { width: 500, height: 500 }; export const LIVE_SIMULATION_NAME = "Binding Affinity Simulation"; -export const PROMPT_TO_ADJUST_B = 8; + +export const VIEW_SWITCH_ID = "view-switch"; +export const START_EXPERIMENT_ID = "start-experiment"; +export const MIX_AGENTS_ID = "mix-agents"; +export const PLAY_BUTTON_ID = "play-button"; +export const RECORD_BUTTON_ID = "record-button"; +export const CHANGE_CONCENTRATION_ID = "change-concentration-slider"; + +export const EQUILIBRIUM_QUIZ_ID = "equilibrium-quiz"; +export const KD_QUIZ_ID = "kd-quiz"; + +export type ProgressionElement = + | typeof VIEW_SWITCH_ID + | typeof START_EXPERIMENT_ID + | typeof MIX_AGENTS_ID + | typeof PLAY_BUTTON_ID + | typeof RECORD_BUTTON_ID + | typeof CHANGE_CONCENTRATION_ID; diff --git a/src/content/Competitive.tsx b/src/content/Competitive.tsx new file mode 100644 index 0000000..4937475 --- /dev/null +++ b/src/content/Competitive.tsx @@ -0,0 +1,208 @@ +import { A, AB, AD, B, C, D } from "../components/agent-symbols"; +import Definition from "../components/shared/Definition"; +import StartExperiment from "../components/StartExperiment"; +import { + PLAY_BUTTON_ID, + RECORD_BUTTON_ID, + START_EXPERIMENT_ID, +} from "../constants"; +import { LayoutType, PageContent, Section } from "../types"; + +export const competitiveArray: PageContent[] = [ + // making the content array 1 indexed to match the page numbers + { + title: "Competitive Binding", + content: ( + <> +

+ We now know how to find the binding affinity of a ligand for + a binding partner. In the Competitive Binding module, we + have developed a competitive inhibitor that binds in the + same manner as our ligand and we want to figure out how well + it blocks the binding. +

+

+ In this scenario we cannot directly measure the formation of + the complex with the inhibitor, so we have to measure how + much the addition of the inhibitor reduces the signal from + our original complex. +

+ + ), + acknowledgment: ( + <> +
+ David S. Goodsell, RCSB Protein Data Bank and Springer + Nature; doi: 10.2210/rcsb_pdb/goodsell-gallery-025 +
+
+ This painting shows a cross section through SARS-CoV-2 + surrounded by blood plasma, with neutralizing antibodies in + bright yellow. The painting was commissioned for the cover + of a special COVID-19 issue of Nature, presented 20 August + 2020, and is currently in the collection of the Cultural + Programs of the National Academy of Sciences. +
+ + ), + section: Section.LandingPage, + layout: LayoutType.FullScreenOverlay, + }, + { + title: "Competitive Binding", + content: ( + <> + We now know the binding affinity of
and . Our + competitive inhibitor, , also binds to and we want to + figure out how effective it is at blocking the binding of . + We cannot directly measure the formation of (which is why + it's shown in grey). + + ), + layout: LayoutType.LiveSimulation, + section: Section.Introduction, + progressionElement: PLAY_BUTTON_ID, + callToAction: + "Press play and watch the how the two different complexes form over time.", + }, + + { + title: "Start the experiment", + content: ( + <> + Now, let's use this simulation to make measurements. We'll start + with [] = 0 so we can get the formation of without + any competition. + + ), + actionButton: , + callToAction: ( + <> + Click the Start experiment button to reset the + simulation and begin by pressing play! + + ), + section: Section.Introduction, + layout: LayoutType.LiveSimulation, + progressionElement: START_EXPERIMENT_ID, + }, + { + title: "Start the experiment", + content: ( + <> + Now, let's use this simulation to make measurements. We'll start + with [] = 0 so we can get the formation of without + any competition. + + ), + actionButton: , + callToAction: ( + <> + Click the Start experiment button to reset the + simulation and begin by pressing play! + + ), + section: Section.Experiment, + layout: LayoutType.LiveSimulation, + progressionElement: PLAY_BUTTON_ID, + }, + { + title: "Find maximum complex formation", + content: ( + <> + With [] = 0, the complex is able to form without any + inhibition. This will be our baseline for the max amount of{" "} + that can form at these concentrations.{" "} + + ), + callToAction: ( + <> + Watch the Concentration over time plot until + you think the reaction has reached equilibrium. Then, press the{" "} + Record button to record the equilibrium + concentration. + + ), + section: Section.Experiment, + layout: LayoutType.LiveSimulation, + progressionElement: RECORD_BUTTON_ID, + }, + { + title: "Introduce the competitive inhibitor", + content: ( + <> + Now let's see how the addition of affects the formation of{" "} + . + + ), + callToAction: ( + <> + If you haven’t already done so, pause the + simulation and use the now-visible interactive slider under{" "} + Agent concentrations to adjust the + concentration of and play the simulation + again. + + ), + section: Section.Experiment, + layout: LayoutType.LiveSimulation, + progressionElement: PLAY_BUTTON_ID, + }, + { + title: "Repeating the experiment", + content: ( + <> + We want to understand the effect + has on the formation of . Let’s repeat the + experiment with a new concentration of . We will keep the + concentration of and constant. + + ), + callToAction: ( + <> + For each new concentration of , determine when equilibrium + has been reached and then press the Record{" "} + button to plot their equilibrium concentrations. + + ), + section: Section.Experiment, + layout: LayoutType.LiveSimulation, + progressionElement: RECORD_BUTTON_ID, + }, + { + title: "Deriving Ki", + content: ( + <> + The constant Ki is analogous to Kd for + inhibitors. Ki can be determined in this experiment + by finding the IC50, the concentration of inhibitor + where the amount of + complex is reduced by half. + + ), + moreInfo: ( + <> + K50 = [] (at equilibrium when [] is half + of max) + + ), + callToAction: ( + <> + Let’s find K50 - Repeat the experiment by pausing, + adjusting the concentration of and recording the + equilibrium point until you have enough data. + + ), + section: Section.Experiment, + layout: LayoutType.LiveSimulation, + }, + { + content: + "Congratulations, you’ve completed the High Affinity experiment!", + backButton: true, + // nextButton: true, + nextButtonText: "View examples", + section: Section.BonusContent, + layout: LayoutType.FullScreenOverlay, + }, +]; diff --git a/src/content/HighAffinity.tsx b/src/content/HighAffinity.tsx index 921b7fd..0840a78 100644 --- a/src/content/HighAffinity.tsx +++ b/src/content/HighAffinity.tsx @@ -1,8 +1,18 @@ import BindingDiagrams from "../components/BindingDiagrams"; +import MixButton from "../components/MixButton"; import StartExperiment from "../components/StartExperiment"; import { AB, A, B } from "../components/agent-symbols"; import KdDerivation from "../components/modals/KdDerivation"; import Definition from "../components/shared/Definition"; +import { + CHANGE_CONCENTRATION_ID, + EQUILIBRIUM_QUIZ_ID, + MIX_AGENTS_ID, + PLAY_BUTTON_ID, + RECORD_BUTTON_ID, + START_EXPERIMENT_ID, + VIEW_SWITCH_ID, +} from "../constants"; import { PageContent, Section, LayoutType } from "../types"; export const highAffinityContentArray: PageContent[] = [ @@ -53,6 +63,7 @@ export const highAffinityContentArray: PageContent[] = [ "Click or tap the animated button to switch your view to a molecular simulation.", section: Section.Introduction, layout: LayoutType.LiveSimulation, + progressionElement: VIEW_SWITCH_ID, }, { content: ( @@ -71,6 +82,7 @@ export const highAffinityContentArray: PageContent[] = [ ), section: Section.Introduction, layout: LayoutType.LiveSimulation, + progressionElement: PLAY_BUTTON_ID, }, { content: ( @@ -90,12 +102,14 @@ export const highAffinityContentArray: PageContent[] = [ ), section: Section.Introduction, layout: LayoutType.LiveSimulation, + progressionElement: VIEW_SWITCH_ID, }, { content: ( <> The clear liquid is slowly turning yellow as the simulation - progresses. Can you estimate the concentration of ? + progresses, but it is taking a long time to change. Can you + estimate the concentration of ? ), callToAction: ( @@ -105,13 +119,89 @@ export const highAffinityContentArray: PageContent[] = [ ), section: Section.Introduction, layout: LayoutType.LiveSimulation, + progressionElement: VIEW_SWITCH_ID, + }, + { + content: ( + <> + The two populations started on opposite sides of the window, so + they have to diffuse before they can bind, which takes awhile. + Let's randomize their positions so we are only looking at the + binding events. + + ), + callToAction: ( + <> + Click the Randomize positions button, then press{" "} + play again! + + ), + actionButton: , + section: Section.Introduction, + layout: LayoutType.LiveSimulation, + progressionElement: MIX_AGENTS_ID, + }, + { + content: ( + <> + The two populations started on opposite sides of the window, so + they have to diffuse before they can bind, which takes awhile. + Let's randomize their positions so we are only looking at the + binding events. + + ), + callToAction: ( + <> + Click the Randomize positions button, then press{" "} + play again! + + ), + actionButton: , + section: Section.Introduction, + layout: LayoutType.LiveSimulation, + progressionElement: PLAY_BUTTON_ID, + }, + { + content: ( + <>Randomizing the positions is simulating a well-mixed solution. + ), + callToAction: ( + <> + Click Lab view to see what the cuvette looks + like now. + + ), + + section: Section.Introduction, + layout: LayoutType.LiveSimulation, + progressionElement: VIEW_SWITCH_ID, + }, + { + content: ( + <> + There are now many more bound complexes in the "solution", so + our measured indicator (in this case, color) is now much + stronger for roughly the same amount of time. + + ), + callToAction: ( + <> + Click Molecular view to switch back to the + simulation. + + ), + + section: Section.Introduction, + layout: LayoutType.LiveSimulation, + progressionElement: VIEW_SWITCH_ID, }, { title: "Start the experiment", content: ( <> - Now, let's use this simulation to make measurements. We're going - to increase the timestep so the experiments are fast. + Now, let's use this simulation to make measurements. We'll start + with randomized positions and increase the timestep so the + experiments are fast. ), actionButton: , @@ -123,13 +213,15 @@ export const highAffinityContentArray: PageContent[] = [ ), section: Section.Introduction, layout: LayoutType.LiveSimulation, + progressionElement: START_EXPERIMENT_ID, }, { title: "Start the experiment", content: ( <> - Now, let's use this simulation to make measurements. We're going - to increase the timestep so the experiments are fast. + Now, let's use this simulation to make measurements. We'll start + with randomized positions and increase the timestep so the + experiments are fast. ), actionButton: , @@ -141,6 +233,7 @@ export const highAffinityContentArray: PageContent[] = [ ), section: Section.Experiment, layout: LayoutType.LiveSimulation, + progressionElement: PLAY_BUTTON_ID, }, { title: "Identifying equilibrium", @@ -163,6 +256,7 @@ export const highAffinityContentArray: PageContent[] = [ ), section: Section.Experiment, layout: LayoutType.LiveSimulation, + progressionElement: RECORD_BUTTON_ID, }, { title: "Affinity", @@ -184,6 +278,8 @@ export const highAffinityContentArray: PageContent[] = [ ), section: Section.Experiment, layout: LayoutType.LiveSimulation, + progressionElement: CHANGE_CONCENTRATION_ID, + quizQuestion: EQUILIBRIUM_QUIZ_ID, }, { title: "Repeating the experiment", @@ -207,6 +303,7 @@ export const highAffinityContentArray: PageContent[] = [ ), section: Section.Experiment, layout: LayoutType.LiveSimulation, + progressionElement: RECORD_BUTTON_ID, }, { title: "Deriving Kd", diff --git a/src/content/LowAffinity.tsx b/src/content/LowAffinity.tsx index e1005b4..f6726c8 100644 --- a/src/content/LowAffinity.tsx +++ b/src/content/LowAffinity.tsx @@ -1,4 +1,5 @@ import { A, B, C } from "../components/agent-symbols"; +import { PLAY_BUTTON_ID } from "../constants"; import { LayoutType, PageContent, Section } from "../types"; export const lowAffinityContentArray: PageContent[] = [ @@ -9,7 +10,7 @@ export const lowAffinityContentArray: PageContent[] = [ layout: LayoutType.FullScreenOverlay, }, { - title: "Experiment with a binding partner", + title: "Experiment with a different binding partner", content: ( <> Molecule has a different binding affinity with molecule{" "} @@ -29,6 +30,7 @@ export const lowAffinityContentArray: PageContent[] = [ concentration. ), + progressionElement: PLAY_BUTTON_ID, }, { title: "Determining Kd", diff --git a/src/content/index.tsx b/src/content/index.tsx index d016248..5561541 100644 --- a/src/content/index.tsx +++ b/src/content/index.tsx @@ -1,21 +1,22 @@ import { Module, PageContent } from "../types"; import { highAffinityContentArray } from "./HighAffinity"; import { lowAffinityContentArray } from "./LowAffinity"; +import { competitiveArray } from "./Competitive"; export const moduleNames = { [Module.A_B_AB]: "High Affinity", [Module.A_C_AC]: "Low Affinity", - [Module.A_B_C_AB_AC]: "Competitive Binding", + [Module.A_B_D_AB]: "Competitive Binding", }; export const FIRST_PAGE = { [Module.A_B_AB]: 0, // landing page [Module.A_C_AC]: 1, - [Module.A_B_C_AB_AC]: 1, + [Module.A_B_D_AB]: 0, }; export default { [Module.A_B_AB]: highAffinityContentArray, [Module.A_C_AC]: lowAffinityContentArray, - [Module.A_B_C_AB_AC]: [], // Add appropriate PageContent[] for Competitive Binding + [Module.A_B_D_AB]: competitiveArray, } as { [key in Module]: PageContent[] }; diff --git a/src/simulation/BindingInstance.ts b/src/simulation/BindingInstance.ts index 8435f1d..5b886d5 100644 --- a/src/simulation/BindingInstance.ts +++ b/src/simulation/BindingInstance.ts @@ -144,12 +144,13 @@ class BindingInstance extends Circle { } this.setPosition(this.pos.x + xStep, this.pos.y + yStep); if (this.child) { + const child = this.child; // first check if it will unbind, otherwise rotate const unbind = this.checkWillUnbind(this.child); if (!unbind) { this.rotateGroup(xStep, yStep); } else { - return true; + return child; } } } diff --git a/src/simulation/BindingSimulator2D.ts b/src/simulation/BindingSimulator2D.ts index 36b8d3e..09360a4 100644 --- a/src/simulation/BindingSimulator2D.ts +++ b/src/simulation/BindingSimulator2D.ts @@ -12,7 +12,7 @@ import { VisTypes, } from "@aics/simularium-viewer"; -import { InputAgent, ProductName, StoredAgent } from "../types"; +import { AgentName, InputAgent, ProductName, StoredAgent } from "../types"; import LiveSimulationData from "./LiveSimulationData"; import { LIVE_SIMULATION_NAME } from "../constants"; import BindingInstance from "./BindingInstance"; @@ -27,41 +27,87 @@ export default class BindingSimulator implements IClientSimulatorImpl { static: boolean = false; initialState: boolean = true; currentNumberBound: number = 0; + currentComplexMap: Map = new Map(); currentNumberOfBindingEvents: number = 0; currentNumberOfUnbindingEvents: number = 0; onUpdate: (data: number) => void = () => {}; - mixCheckAgent: number = 0; numberAgentOnLeft: number = 0; numberAgentOnRight: number = 0; - productColor: string = ""; + productColor: Map; size: number; constructor( agents: InputAgent[], size: number, productColor: string, + initPositions: "random" | "sorted" = "sorted", timeFactor: number = LiveSimulationData.DEFAULT_TIME_FACTOR ) { this.size = size; - this.productColor = productColor; + this.productColor = new Map(); this.system = new System(); this.createBoundingLines(); this.distanceFactor = 40; this.timeFactor = timeFactor; - this.agents = this.initializeAgents(agents); + this.agents = this.initializeAgents(agents, initPositions); this.currentFrame = 0; this.system.separate(); } private clearAgents() { this.currentNumberBound = 0; + this.currentComplexMap.clear(); + this.productColor.clear(); this.currentNumberOfBindingEvents = 0; this.currentNumberOfUnbindingEvents = 0; this.system = new System(); this.instances = []; } - private initializeAgents(agents: InputAgent[]): StoredAgent[] { - let largestRadius = 0; + private getProductIdByProductName(productName: ProductName) { + let agent1: InputAgent | undefined; + let agent2: InputAgent | undefined; + switch (productName) { + case ProductName.AB: + agent1 = this.agents.find((a) => a.name === AgentName.A); + agent2 = this.agents.find((a) => a.name === AgentName.B); + break; + case ProductName.AC: + agent1 = this.agents.find((a) => a.name === AgentName.A); + agent2 = this.agents.find((a) => a.name === AgentName.C); + break; + case ProductName.AD: + agent1 = this.agents.find((a) => a.name === AgentName.A); + agent2 = this.agents.find((a) => a.name === AgentName.D); + break; + } + if (!agent1 || !agent2) { + throw new Error("Invalid product name"); + } + return this.getProductIdByAgents(agent1, agent2); + } + + private getProductIdByAgents( + agent1: BindingInstance | InputAgent, + agent2: BindingInstance | InputAgent + ) { + if (agent1.id > agent2.id) { + return `${agent1.id}#${agent2.id}`; + } else { + return `${agent2.id}#${agent1.id}`; + } + } + + private getRandomPoint() { + return [ + random(-this.size / 2, this.size / 2, true), + random(-this.size / 2, this.size / 2, true), + ]; + } + + private initializeAgents( + agents: InputAgent[], + initPositions: "random" | "sorted" = "sorted" + ): StoredAgent[] { for (let i = 0; i < agents.length; ++i) { const agent = agents[i] as StoredAgent; // count is no longer optional // if this is called from the constructor, the count will be undefined @@ -72,16 +118,21 @@ export default class BindingSimulator implements IClientSimulatorImpl { agent.initialConcentration ); } - if (agent.radius > largestRadius) { - // use the largest agent to check if the system is mixed - largestRadius = agent.radius; - this.mixCheckAgent = agent.id; + if (agent.complexColor) { + this.productColor.set(agent.id, agent.complexColor); } + + this.currentComplexMap.set(agent.id.toString(), 0); + for (let j = 0; j < agent.count; ++j) { - const position: number[] = this.getRandomPointOnSide( - agent.id, - agents.length - ); + let position: number[] = []; + if (initPositions === "random") { + // if we're mixing agents, we want to randomize the position + // of the agents on the sides of the bounding box + position = this.getRandomPoint(); + } else { + position = this.getRandomPointOnSide(agent.id); + } const circle = new Circle( new Vector(...position), agent.radius @@ -146,17 +197,12 @@ export default class BindingSimulator implements IClientSimulatorImpl { return concentration; } - private getRandomPointOnSide(side: number, total: number) { + private getRandomPointOnSide(side: number) { const size = this.size; const buffer = size / 20; const dFromSide = random(0 + buffer, size / 2, true); - let dAlongSide = random(-size / 2, size / 2, true); + const dAlongSide = random(-size / 2, size / 2, true); - if (total > 2 && side === 1) { - dAlongSide = random(0, size / 2, true); - } else if (total > 2 && side === 2) { - dAlongSide = random(-size / 2, 0, true); - } switch (side) { case 0: return [-dFromSide, dAlongSide]; @@ -186,7 +232,18 @@ export default class BindingSimulator implements IClientSimulatorImpl { }; } - public changeConcentration(agentId: number, newConcentration: number) { + public mixAgents() { + this.clearAgents(); + this.initializeAgents(this.agents, "random"); + this.static = true; + this.initialState = false; + } + + public changeConcentration( + agentId: number, + newConcentration: number, + initPositions: "random" | "sorted" + ) { const agent = find(this.agents, (agent) => agent.id === agentId); if (!agent) { return; @@ -201,16 +258,19 @@ export default class BindingSimulator implements IClientSimulatorImpl { // initial state this.clearAgents(); this.initialState = true; - this.initializeAgents(this.agents); + this.initializeAgents(this.agents, initPositions); return; } const diff = newCount - oldCount; if (diff > 0) { for (let i = 0; i < diff; ++i) { - const position: number[] = this.getRandomPointOnSide( - agent.id, - this.agents.length - ); + let position: number[]; + if (initPositions === "random") { + position = this.getRandomPoint(); + } else { + position = this.getRandomPointOnSide(agent.id); + } + const circle = new Circle( new Vector(...position), agent.radius @@ -257,13 +317,16 @@ export default class BindingSimulator implements IClientSimulatorImpl { const init = <{ [key: string]: number }>{}; const concentrations = this.agents.reduce((acc, agent) => { acc[agent.name] = this.convertCountToConcentration( - agent.count - this.currentNumberBound + agent.count - this.currentComplexMap.get(agent.id.toString())! ); return acc; }, init); - concentrations[product] = this.convertCountToConcentration( - this.currentNumberBound - ); + const productId = this.getProductIdByProductName(product); + if (productId) { + concentrations[product] = this.convertCountToConcentration( + this.currentComplexMap.get(productId) || 0 + ); + } return concentrations; } @@ -271,13 +334,15 @@ export default class BindingSimulator implements IClientSimulatorImpl { const agentData: number[] = []; for (let ii = 0; ii < this.instances.length; ++ii) { const instance = this.instances[ii]; + let typeId = instance.id; + if (instance.parent) { + typeId = this.getBoundTypeId(instance.id, instance.parent.id); + } else if (instance.child) { + typeId = this.getBoundTypeId(instance.id, instance.child.id); + } agentData.push(VisTypes.ID_VIS_TYPE_DEFAULT); // vis type agentData.push(ii); // instance id - agentData.push( - instance.bound || instance.child - ? 100 + instance.id - : instance.id - ); // type + agentData.push(typeId); // type agentData.push(instance.pos.x); // x agentData.push(instance.pos.y); // y agentData.push(0); // z @@ -292,13 +357,14 @@ export default class BindingSimulator implements IClientSimulatorImpl { private updateAgentsPositions() { for (let i = 0; i < this.instances.length; ++i) { - const unbindingOccurred = this.instances[i].oneStep( + const releasedChild = this.instances[i].oneStep( this.size, this.timeFactor ); - if (unbindingOccurred) { + if (releasedChild) { this.currentNumberOfUnbindingEvents++; this.currentNumberBound--; + this.incrementBoundCounts(this.instances[i], releasedChild, -1); } } } @@ -332,6 +398,27 @@ export default class BindingSimulator implements IClientSimulatorImpl { } } + private incrementBoundCounts( + a: BindingInstance, + b: BindingInstance, + amount: number + ) { + const complexName = this.getProductIdByAgents(a, b); + this.currentComplexMap.set( + complexName, + (this.currentComplexMap.get(complexName) || 0) + amount + ); + + const previousValue = this.currentComplexMap.get(a.id.toString()) || 0; + const nextValue = previousValue + amount; + + this.currentComplexMap.set(a.id.toString(), nextValue); + this.currentComplexMap.set( + b.id.toString(), + (this.currentComplexMap.get(b.id.toString()) || 0) + amount + ); + } + private resolveBindingReactions() { this.system.checkAll((response: Response) => { const { a, b, overlapV } = response; @@ -349,9 +436,10 @@ export default class BindingSimulator implements IClientSimulatorImpl { if (unbound) { this.currentNumberOfUnbindingEvents++; this.currentNumberBound--; + this.incrementBoundCounts(a, b, -1); } } - if (a.partners.includes(b.id)) { + if (a.partners.includes(b.id) && !a.isBoundPair(b)) { // a is the ligand let bound = false; if (a.r < b.r) { @@ -361,6 +449,7 @@ export default class BindingSimulator implements IClientSimulatorImpl { bound = a.checkWillBind(b, overlapV); } if (bound) { + this.incrementBoundCounts(a, b, 1); this.currentNumberOfBindingEvents++; this.currentNumberBound++; } @@ -417,11 +506,16 @@ export default class BindingSimulator implements IClientSimulatorImpl { }; } + private getBoundTypeId(id: number, partnerId: number) { + return 100 + id * 10 + partnerId; + } + public getInfo(): TrajectoryFileInfo { const typeMapping: EncodedTypeMapping = {}; const size = this.size; for (let i = 0; i < this.agents.length; ++i) { - typeMapping[this.agents[i].id] = { + const id = this.agents[i].id; + typeMapping[id] = { name: `${this.agents[i].name}`, geometry: { color: this.agents[i].color, @@ -429,14 +523,27 @@ export default class BindingSimulator implements IClientSimulatorImpl { url: "", }, }; - typeMapping[this.agents[i].id + 100] = { - name: `${this.agents[i].name}#bound`, - geometry: { - color: this.productColor, - displayType: GeometryDisplayType.SPHERE, - url: "", - }, - }; + if (this.agents[i].partners.length > 0) { + for (let j = 0; j < this.agents[i].partners.length; ++j) { + const partnerId = this.agents[i].partners[j]; + const complexId = this.getBoundTypeId(id, partnerId); + const partner = this.agents.find((a) => a.id === partnerId); + if (!partner) { + continue; + } + typeMapping[complexId] = { + name: `${this.agents[i].name}#${partner.name}`, + geometry: { + color: + this.productColor.get(partnerId) || + this.productColor.get(id) || + "", + displayType: GeometryDisplayType.SPHERE, + url: "", + }, + }; + } + } } return { // TODO get msgType and connId out of here diff --git a/src/simulation/ISimulationData.ts b/src/simulation/ISimulationData.ts index b65296f..31ca66d 100644 --- a/src/simulation/ISimulationData.ts +++ b/src/simulation/ISimulationData.ts @@ -1,12 +1,14 @@ import { AGENT_AB_COLOR, AGENT_AC_COLOR, + AGENT_AD_COLOR, AGENT_A_COLOR, AGENT_B_COLOR, AGENT_C_COLOR, + AGENT_D_COLOR, } from "../constants/colors"; import { - AgentFunction, + AgentType, AgentName, CurrentConcentration, InputAgent, @@ -15,11 +17,13 @@ import { } from "../types"; export const AGENT_AND_PRODUCT_COLORS = { - [AgentFunction.Fixed]: AGENT_A_COLOR, - [AgentFunction.Adjustable]: AGENT_B_COLOR, - [AgentFunction.Competitor]: AGENT_C_COLOR, - [AgentFunction.Complex_1]: AGENT_AB_COLOR, - [AgentFunction.Complex_2]: AGENT_AC_COLOR, + [AgentType.Fixed]: AGENT_A_COLOR, + [AgentType.Adjustable_1]: AGENT_B_COLOR, + [AgentType.Adjustable_2]: AGENT_C_COLOR, + [AgentType.Competitor]: AGENT_D_COLOR, + [AgentType.Complex_1]: AGENT_AB_COLOR, + [AgentType.Complex_2]: AGENT_AC_COLOR, + [AgentType.Complex_3]: AGENT_AD_COLOR, }; export enum TrajectoryType { @@ -31,11 +35,12 @@ interface ISimulationData { type: TrajectoryType; getCurrentProduct: (module: Module) => ProductName; getMaxConcentration: (module: Module) => number; - getAgentFunction: (name: AgentName | ProductName) => AgentFunction; + getAgentFunction: (name: AgentName | ProductName) => AgentType; getAgentColor: (agentName: AgentName | ProductName) => string; getActiveAgents: (currentModule: Module) => AgentName[]; getInitialConcentrations: ( - activeAgents: AgentName[] + activeAgents: AgentName[], + module: Module ) => CurrentConcentration; createAgentsFromConcentrations: () => InputAgent[] | null; } diff --git a/src/simulation/LiveSimulationData.ts b/src/simulation/LiveSimulationData.ts index ad780d6..98ef9a8 100644 --- a/src/simulation/LiveSimulationData.ts +++ b/src/simulation/LiveSimulationData.ts @@ -1,5 +1,5 @@ import { - AgentFunction, + AgentType, AgentName, CurrentConcentration, InputAgent, @@ -8,8 +8,12 @@ import { } from "../types"; import { AGENT_A_COLOR, + AGENT_AB_COLOR, + AGENT_AC_COLOR, + AGENT_AD_COLOR, AGENT_B_COLOR, AGENT_C_COLOR, + AGENT_D_COLOR, } from "../constants/colors"; import ISimulationData, { AGENT_AND_PRODUCT_COLORS, @@ -22,7 +26,7 @@ const agentA: InputAgent = { name: AgentName.A, initialConcentration: 0, radius: 3, - partners: [1, 2], + partners: [1, 2, 3], color: AGENT_A_COLOR, }; @@ -32,9 +36,10 @@ const agentB: InputAgent = { initialConcentration: 0, radius: 1, partners: [0], - kOn: 0.9, + kOn: 0.95, kOff: 0.01, color: AGENT_B_COLOR, + complexColor: AGENT_AB_COLOR, }; const agentC: InputAgent = { @@ -46,26 +51,41 @@ const agentC: InputAgent = { kOn: 0.3, kOff: 0.9, color: AGENT_C_COLOR, + complexColor: AGENT_AC_COLOR, +}; + +const agentD: InputAgent = { + id: 3, + name: AgentName.D, + initialConcentration: 0, + radius: 1.2, + partners: [0], + kOn: 0.99, + kOff: 0.001, + color: AGENT_D_COLOR, + complexColor: AGENT_AD_COLOR, }; const kds = { [Module.A_B_AB]: 0.75, [Module.A_C_AC]: 74, - [Module.A_B_C_AB_AC]: 5, + [Module.A_B_D_AB]: 1.5, }; export default class LiveSimulation implements ISimulationData { - static NAME_TO_FUNCTION_MAP = { - [AgentName.A]: AgentFunction.Fixed, - [AgentName.B]: AgentFunction.Adjustable, - [AgentName.C]: AgentFunction.Competitor, - [ProductName.AB]: AgentFunction.Complex_1, - [ProductName.AC]: AgentFunction.Complex_2, + static NAME_TO_TYPE_MAP = { + [AgentName.A]: AgentType.Fixed, + [AgentName.B]: AgentType.Adjustable_1, + [AgentName.C]: AgentType.Adjustable_2, + [AgentName.D]: AgentType.Competitor, + [ProductName.AB]: AgentType.Complex_1, + [ProductName.AC]: AgentType.Complex_2, + [ProductName.AD]: AgentType.Complex_3, }; static ADJUSTABLE_AGENT_MAP = { [Module.A_B_AB]: AgentName.B, [Module.A_C_AC]: AgentName.C, - [Module.A_B_C_AB_AC]: AgentName.B, + [Module.A_B_D_AB]: AgentName.D, }; static INITIAL_TIME_FACTOR: number = 30; static DEFAULT_TIME_FACTOR: number = 90; @@ -73,16 +93,36 @@ export default class LiveSimulation implements ISimulationData { [AgentName.A]: agentA, [AgentName.B]: agentB, [AgentName.C]: agentC, + [AgentName.D]: agentD, }; static INITIAL_CONCENTRATIONS = { - [AgentName.A]: 10, - [AgentName.B]: 4, - [AgentName.C]: 30, + [Module.A_B_AB]: { + [AgentName.A]: 5, + [AgentName.B]: 4, + }, + [Module.A_C_AC]: { + [AgentName.A]: 5, + [AgentName.C]: 30, + }, + [Module.A_B_D_AB]: { + [AgentName.A]: 2, + [AgentName.B]: 2, + [AgentName.D]: 2, + }, + }; + // for competitive binding we want to start the experiment with zero D but + // still have it in the introduction + static EXPERIMENT_CONCENTRATIONS = { + ...this.INITIAL_CONCENTRATIONS, + [Module.A_B_D_AB]: { + ...this.INITIAL_CONCENTRATIONS[Module.A_B_D_AB], + [AgentName.D]: 0, + }, }; PRODUCT = { [Module.A_B_AB]: ProductName.AB, [Module.A_C_AC]: ProductName.AC, - [Module.A_B_C_AB_AC]: ProductName.AB, + [Module.A_B_D_AB]: ProductName.AB, }; timeUnit = NANO; type = TrajectoryType.live; @@ -91,11 +131,11 @@ export default class LiveSimulation implements ISimulationData { return this.PRODUCT[module]; }; - getAgentFunction = (name: AgentName | ProductName): AgentFunction => { + getAgentFunction = (name: AgentName | ProductName): AgentType => { return ( - LiveSimulation.NAME_TO_FUNCTION_MAP as Record< + LiveSimulation.NAME_TO_TYPE_MAP as Record< AgentName | ProductName, - AgentFunction + AgentType > )[name]; }; @@ -109,21 +149,34 @@ export default class LiveSimulation implements ISimulationData { let maxConcentration = 0; switch (module) { case Module.A_B_AB: - maxConcentration = 10; + maxConcentration = 5; break; case Module.A_C_AC: maxConcentration = 75; break; - case Module.A_B_C_AB_AC: - maxConcentration = 20; //TODO: adjust these as needed + case Module.A_B_D_AB: + maxConcentration = 10; break; } return maxConcentration; }; createAgentsFromConcentrations = ( - activeAgents?: AgentName[] + activeAgents?: AgentName[], + module?: Module, + isExperiment: boolean = false ): InputAgent[] => { + if (!module) { + throw new Error("Module must be specified to create agents."); + } + if (!activeAgents) { + activeAgents = this.getActiveAgents(module); + } + const concentrations = this.getInitialConcentrations( + activeAgents, + module, + isExperiment + ); return (activeAgents ?? []).map((agentName: AgentName) => { const agent = { ...( @@ -133,10 +186,8 @@ export default class LiveSimulation implements ISimulationData { > )[agentName], }; - agent.initialConcentration = - LiveSimulation.INITIAL_CONCENTRATIONS[ - agentName as keyof typeof LiveSimulation.INITIAL_CONCENTRATIONS - ]; + + agent.initialConcentration = concentrations[agentName] ?? 0; return agent; }); }; @@ -147,23 +198,25 @@ export default class LiveSimulation implements ISimulationData { return [AgentName.A, AgentName.B]; case Module.A_C_AC: return [AgentName.A, AgentName.C]; - case Module.A_B_C_AB_AC: - return [AgentName.A, AgentName.B, AgentName.C]; + case Module.A_B_D_AB: + return [AgentName.A, AgentName.B, AgentName.D]; default: return []; } }; // filters down to the active agents getInitialConcentrations = ( - activeAgents: AgentName[] + activeAgents: AgentName[], + module: Module, + isExperiment: boolean = false ): CurrentConcentration => { + const concentrations = isExperiment + ? { ...LiveSimulation.EXPERIMENT_CONCENTRATIONS[module] } + : { ...LiveSimulation.INITIAL_CONCENTRATIONS[module] }; return activeAgents.reduce((acc, agent) => { return { ...acc, - [agent]: - LiveSimulation.INITIAL_CONCENTRATIONS[ - agent as keyof typeof LiveSimulation.INITIAL_CONCENTRATIONS - ], + [agent]: (concentrations as Record)[agent], }; }, {}); }; diff --git a/src/simulation/PreComputedSimulationData.ts b/src/simulation/PreComputedSimulationData.ts index 913c9d3..0de2c7c 100644 --- a/src/simulation/PreComputedSimulationData.ts +++ b/src/simulation/PreComputedSimulationData.ts @@ -1,5 +1,5 @@ import { - AgentFunction, + AgentType, AgentName, CurrentConcentration, InputAgent, @@ -13,17 +13,17 @@ import ISimulationData, { import { MICRO } from "../constants"; export default class PreComputedSimulationData implements ISimulationData { - static NAME_TO_FUNCTION_MAP = { - [AgentName.Antibody]: AgentFunction.Fixed, - [AgentName.Antigen]: AgentFunction.Adjustable, - [ProductName.AntibodyAntigen]: AgentFunction.Complex_1, + static NAME_TO_TYPE_MAP = { + [AgentName.Antibody]: AgentType.Fixed, + [AgentName.Antigen]: AgentType.Adjustable_1, + [ProductName.AntibodyAntigen]: AgentType.Complex_1, }; static EXAMPLE_TRAJECTORY_URLS = { [Module.A_B_AB]: "https://aics-simularium-data.s3.us-east-2.amazonaws.com/trajectory/binding-affinity_antibodies.simularium", [Module.A_C_AC]: "https://aics-simularium-data.s3.us-east-2.amazonaws.com/trajectory/binding-affinity_hemoglobin.simularium", - [Module.A_B_C_AB_AC]: + [Module.A_B_D_AB]: "https://aics-simularium-data.s3.us-east-2.amazonaws.com/trajectory/binding-affinity_hemoglobin-co.simularium", }; @@ -32,7 +32,7 @@ export default class PreComputedSimulationData implements ISimulationData { PRODUCT = { [Module.A_B_AB]: ProductName.AntibodyAntigen, [Module.A_C_AC]: ProductName.Hemoglobin, - [Module.A_B_C_AB_AC]: ProductName.Hemoglobin, + [Module.A_B_D_AB]: ProductName.Hemoglobin, }; type = TrajectoryType.precomputed; @@ -49,18 +49,18 @@ export default class PreComputedSimulationData implements ISimulationData { case Module.A_C_AC: maxConcentration = 20; break; - case Module.A_B_C_AB_AC: + case Module.A_B_D_AB: maxConcentration = 20; break; } return maxConcentration; }; - getAgentFunction = (name: AgentName | ProductName): AgentFunction => { + getAgentFunction = (name: AgentName | ProductName): AgentType => { return ( - PreComputedSimulationData.NAME_TO_FUNCTION_MAP as Record< + PreComputedSimulationData.NAME_TO_TYPE_MAP as Record< AgentName | ProductName, - AgentFunction + AgentType > )[name]; }; diff --git a/src/simulation/context.tsx b/src/simulation/context.tsx index 0021589..7c2f3c5 100644 --- a/src/simulation/context.tsx +++ b/src/simulation/context.tsx @@ -8,57 +8,64 @@ import { DEFAULT_VIEWPORT_SIZE, LIVE_SIMULATION_NAME, NANO, + ProgressionElement, } from "../constants"; import { AgentName, Module, ProductName, Section } from "../types"; interface SimulariumContextType { - trajectoryName: string; - productName: ProductName; adjustableAgentName: AgentName; + currentProductionConcentration: number; fixedAgentStartingConcentration: number; - maxConcentration: number; getAgentColor: (agentName: AgentName | ProductName) => string; - currentProductionConcentration: number; - isPlaying: boolean; - setIsPlaying: (value: boolean) => void; - simulariumController: SimulariumController | null; - handleTimeChange: (timeData: TimeData) => void; + handleMixAgents: () => void; handleStartExperiment: () => void; - section: Section; - setPage: (value: number) => void; + handleTimeChange: (timeData: TimeData) => void; + handleTrajectoryChange: (value: TrajectoryFileInfo) => void; + isPlaying: boolean; + maxConcentration: number; module: Module; - setModule: (value: Module) => void; page: number; + productName: ProductName; + progressionElement: ProgressionElement | ""; + quizQuestion: string; + recordedConcentrations: number[]; + section: Section; + setIsPlaying: (value: boolean) => void; + setModule: (value: Module) => void; + setPage: (value: number) => void; + setViewportSize: (value: { width: number; height: number }) => void; + simulariumController: SimulariumController | null; timeFactor: number; timeUnit: string; - handleTrajectoryChange: (value: TrajectoryFileInfo) => void; + trajectoryName: string; viewportSize: { width: number; height: number }; - setViewportSize: (value: { width: number; height: number }) => void; - recordedConcentrations: number[]; } export const SimulariumContext = createContext({ - trajectoryName: LIVE_SIMULATION_NAME, adjustableAgentName: AgentName.B, - productName: ProductName.AB, + currentProductionConcentration: 0, fixedAgentStartingConcentration: 0, - maxConcentration: 10, getAgentColor: () => "", - currentProductionConcentration: 0, - section: Section.Introduction, - isPlaying: false, - setIsPlaying: () => {}, - simulariumController: null, - handleTimeChange: () => {}, + handleMixAgents: () => {}, handleStartExperiment: () => {}, - setPage: () => {}, + handleTimeChange: () => {}, + handleTrajectoryChange: () => {}, + isPlaying: false, + maxConcentration: 10, + module: Module.A_B_AB, page: 0, + productName: ProductName.AB, + progressionElement: "", + quizQuestion: "", + recordedConcentrations: [], + section: Section.Introduction, + setIsPlaying: () => {}, setModule: () => {}, - module: Module.A_B_AB, + setPage: () => {}, + setViewportSize: () => {}, + simulariumController: null, timeFactor: 30, timeUnit: NANO, - handleTrajectoryChange: () => {}, + trajectoryName: LIVE_SIMULATION_NAME, viewportSize: DEFAULT_VIEWPORT_SIZE, - setViewportSize: () => {}, - recordedConcentrations: [], } as SimulariumContextType); diff --git a/src/types/index.ts b/src/types/index.ts index 1320a50..391c328 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,11 +6,12 @@ import { MouseEvent as ReactMouseEvent, MouseEventHandler, } from "react"; +import { ProgressionElement } from "../constants"; export const enum Module { A_B_AB = 1, A_C_AC = 2, - A_B_C_AB_AC = 3, + A_B_D_AB = 3, } export enum Section { @@ -27,18 +28,21 @@ export const enum LayoutType { PreComputedSimulation = "pre-computed-simulation", } -export enum AgentFunction { +export enum AgentType { Fixed = "Fixed", - Adjustable = "Adjustable", + Adjustable_1 = "Adjustable_1", + Adjustable_2 = "Adjustable_2", Competitor = "Competitor", Complex_1 = "Complex_1", Complex_2 = "Complex_2", + Complex_3 = "Complex_3", } export enum AgentName { A = "A", B = "B", C = "C", + D = "D", Antibody = "Antibody", Antigen = "Antigen", } @@ -46,6 +50,7 @@ export enum AgentName { export enum ProductName { AB = "AB", AC = "AC", + AD = "AD", AntibodyAntigen = "Antibody-Antigen", Hemoglobin = "Hemoglobin", } @@ -70,6 +75,7 @@ export interface InputAgent { kOff?: number; count?: number; color: string; + complexColor?: string; } export interface PageContent { @@ -86,6 +92,8 @@ export interface PageContent { backButton?: boolean; nextButtonText?: string; trajectoryUrl?: string; + progressionElement?: ProgressionElement; + quizQuestion?: string; modal?: { title: string; content: string | JSX.Element; diff --git a/src/utils/index.ts b/src/utils/index.ts index 66997b5..96a454c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -59,7 +59,7 @@ export const isSlopeZero = (array: number[], timeFactor: number) => { ); const bestFit = regression.linear(regressionData); const slope = bestFit.equation[0]; - if (Math.abs(slope) < 0.001) { + if (Math.abs(slope) <= 0.01) { return true; } else { return false;