diff options
author | brynnchernosky <56202540+brynnchernosky@users.noreply.github.com> | 2023-01-30 14:14:46 -0500 |
---|---|---|
committer | brynnchernosky <56202540+brynnchernosky@users.noreply.github.com> | 2023-01-30 14:14:46 -0500 |
commit | d5ebbf476aeb7ce3f88e2e4c3961ffed4ed8e61a (patch) | |
tree | ea322151d561c4d035c0508004a37f85357b35d0 | |
parent | d2b8f997f1786c813ef5a58acef69501e1c523a3 (diff) |
start adding physics sim
-rw-r--r-- | src/client/views/nodes/PhysicsSimulationApp.tsx | 2154 | ||||
-rw-r--r-- | src/client/views/nodes/PhysicsSimulationBox.scss | 72 | ||||
-rw-r--r-- | src/client/views/nodes/PhysicsSimulationBox.tsx | 53 | ||||
-rw-r--r-- | src/client/views/nodes/PhysicsSimulationWall.tsx | 35 | ||||
-rw-r--r-- | src/client/views/nodes/PhysicsSimulationWedge.tsx | 64 | ||||
-rw-r--r-- | src/client/views/nodes/PhysicsSimulationWeight.tsx | 913 |
6 files changed, 3238 insertions, 53 deletions
diff --git a/src/client/views/nodes/PhysicsSimulationApp.tsx b/src/client/views/nodes/PhysicsSimulationApp.tsx new file mode 100644 index 000000000..7486aa88d --- /dev/null +++ b/src/client/views/nodes/PhysicsSimulationApp.tsx @@ -0,0 +1,2154 @@ +import AddIcon from "@mui/icons-material/Add"; +import AddCircleIcon from "@mui/icons-material/AddCircle"; +import ClearIcon from "@mui/icons-material/Clear"; +import PauseIcon from "@mui/icons-material/Pause"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import ReplayIcon from "@mui/icons-material/Replay"; +import QuestionMarkIcon from "@mui/icons-material/QuestionMark"; +import ArrowLeftIcon from "@mui/icons-material/ArrowLeft"; +import ArrowRightIcon from "@mui/icons-material/ArrowRight"; +import { SelectChangeEvent } from "@mui/material/Select"; +import { + Alert, + Box, + Button, + Checkbox, + CircularProgress, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Divider, + FormControl, + FormControlLabel, + FormGroup, + IconButton, + InputAdornment, + InputLabel, + LinearProgress, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + MenuItem, + Popover, + Select, + Stack, + TextField, + Tooltip, +} from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { tooltipClasses, TooltipProps } from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import React, { useEffect, useState } from "react"; +import "./PhysicsSimulationBox.scss"; +import { IForce, Weight } from "./PhysicsSimulationWeight"; +import { Description } from "@mui/icons-material"; + +interface VectorTemplate { + top: number; + left: number; + width: number; + height: number; + x1: number; + y1: number; + x2: number; + y2: number; + weightX: number; + weightY: number; +} +interface QuestionTemplate { + questionSetup: string[]; + variablesForQuestionSetup: string[]; + question: string; + answerParts: string[]; + answerSolutionDescriptions: string[]; + goal: string; + hints: { description: string; content: string }[]; +} + +interface TutorialTemplate { + question: string; + steps: { + description: string; + content: string; + forces: { + description: string; + magnitude: number; + directionInDegrees: number; + }[]; + showMagnitude: boolean; + }[]; +} + +function App() { + // Constants + const gravityMagnitude = 9.81; + const forceOfGravity: IForce = { + description: "Gravity", + magnitude: gravityMagnitude, + directionInDegrees: 270, + }; + const HtmlTooltip = styled(({ className, ...props }: TooltipProps) => ( + <Tooltip {...props} classes={{ popper: className }} /> + ))(({ theme }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: "#f5f5f9", + color: "rgba(0, 0, 0, 0.87)", + maxWidth: 220, + fontSize: theme.typography.pxToRem(12), + border: "1px solid #dadde9", + }, + })); + const xMin = 0; + const yMin = 0; + const xMax = window.innerWidth * 0.7; + const yMax = window.innerHeight * 0.8; + const color = `rgba(0,0,0,0.5)`; + + // Variables + let questionVariables: number[] = []; + let reviewCoefficient: number = 0; + + // State variables + const [startPosX, setStartPosX] = useState(0); + const [startPosY, setStartPosY] = useState(0); + const [accelerationXDisplay, setAccelerationXDisplay] = useState(0); + const [accelerationYDisplay, setAccelerationYDisplay] = useState(0); + const [positionXDisplay, setPositionXDisplay] = useState(0); + const [positionYDisplay, setPositionYDisplay] = useState(0); + const [velocityXDisplay, setVelocityXDisplay] = useState(0); + const [velocityYDisplay, setVelocityYDisplay] = useState(0); + + const [startPosX2, setStartPosX2] = useState(0); + const [startPosY2, setStartPosY2] = useState(0); + const [accelerationXDisplay2, setAccelerationXDisplay2] = useState(0); + const [accelerationYDisplay2, setAccelerationYDisplay2] = useState(0); + const [positionXDisplay2, setPositionXDisplay2] = useState(0); + const [positionYDisplay2, setPositionYDisplay2] = useState(0); + const [velocityXDisplay2, setVelocityXDisplay2] = useState(0); + const [velocityYDisplay2, setVelocityYDisplay2] = useState(0); + + const [adjustPendulumAngle, setAdjustPendulumAngle] = useState<{ + angle: number; + length: number; + }>({ angle: 0, length: 0 }); + const [answerInputFields, setAnswerInputFields] = useState(<div></div>); + const [coefficientOfKineticFriction, setCoefficientOfKineticFriction] = + React.useState<number | string | Array<number | string>>(0); + const [coefficientOfStaticFriction, setCoefficientOfStaticFriction] = + React.useState<number | string | Array<number | string>>(0); + const [currentForceSketch, setCurrentForceSketch] = + useState<VectorTemplate | null>(null); + const [deleteMode, setDeleteMode] = useState(false); + const [displayChange, setDisplayChange] = useState<{ + xDisplay: number; + yDisplay: number; + }>({ xDisplay: 0, yDisplay: 0 }); + const [elasticCollisions, setElasticCollisions] = useState<boolean>(false); + const [forceSketches, setForceSketches] = useState<VectorTemplate[]>([]); + const [questionPartOne, setQuestionPartOne] = useState<string>(""); + const [hintDialogueOpen, setHintDialogueOpen] = useState<boolean>(false); + const [mode, setMode] = useState<string>("Freeform"); + const [noMovement, setNoMovement] = useState(false); + const [weight, setWeight] = useState(false); + const [pendulum, setPendulum] = useState(false); + const [pendulumAngle, setPendulumAngle] = useState(0); + const [pendulumLength, setPendulumLength] = useState(300); + const [questionNumber, setQuestionNumber] = useState<number>(0); + const [reviewGravityAngle, setReviewGravityAngle] = useState<number>(0); + const [reviewGravityMagnitude, setReviewGravityMagnitude] = + useState<number>(0); + const [reviewNormalAngle, setReviewNormalAngle] = useState<number>(0); + const [reviewNormalMagnitude, setReviewNormalMagnitude] = useState<number>(0); + const [reviewStaticAngle, setReviewStaticAngle] = useState<number>(0); + const [reviewStaticMagnitude, setReviewStaticMagnitude] = useState<number>(0); + const [selectedQuestion, setSelectedQuestion] = useState<QuestionTemplate>( + questions.inclinePlane[0] + ); + const [selectedTutorial, setSelectedTutorial] = useState<TutorialTemplate>( + tutorials.inclinePlane + ); + const [questionPartTwo, setQuestionPartTwo] = useState<string>(""); + const [selectedSolutions, setSelectedSolutions] = useState<number[]>([]); + const [showAcceleration, setShowAcceleration] = useState<boolean>(false); + const [showForces, setShowForces] = useState<boolean>(true); + const [showVelocity, setShowVelocity] = useState<boolean>(false); + const [simulationPaused, setSimulationPaused] = useState<boolean>(true); + const [simulationReset, setSimulationReset] = useState<boolean>(false); + const [simulationType, setSimulationType] = + useState<string>("Inclined Plane"); + const [sketching, setSketching] = useState(false); + const [startForces, setStartForces] = useState<IForce[]>([forceOfGravity]); + const [startPendulumAngle, setStartPendulumAngle] = useState(0); + const [stepNumber, setStepNumber] = useState<number>(0); + const [timer, setTimer] = useState<number>(0); + const [updatedForces, setUpdatedForces] = useState<IForce[]>([ + forceOfGravity, + ]); + const [wallPositions, setWallPositions] = useState<IWallProps[]>([]); + const [wedge, setWedge] = useState(false); + const [wedgeAngle, setWedgeAngle] = React.useState< + number | string | Array<number | string> + >(26); + const [wedgeHeight, setWedgeHeight] = useState( + Math.tan((26 * Math.PI) / 180) * 400 + ); + const [wedgeWidth, setWedgeWidth] = useState(400); + const [twoWeights, setTwoWeights] = useState(false); + + // Add one weight to the simulation + const addWeight = () => { + setWeight(true); + setTwoWeights(false); + setWedge(false); + setPendulum(false); + }; + + // Add two weights to the simulation + const addTwoWeights = () => { + setWeight(true); + setTwoWeights(true); + setWedge(false); + setPendulum(false); + }; + + // Add a wedge with a One Weight to the simulation + const addWedge = () => { + setWeight(true); + setTwoWeights(false); + setWedge(true); + setPendulum(false); + }; + + // Add a simple pendulum to the simulation + const addPendulum = () => { + setWeight(true); + setTwoWeights(false); + setPendulum(true); + setWedge(false); + }; + + // Update forces when coefficient of static friction changes in freeform mode + const updateForcesWithFriction = ( + coefficient: number, + width: number = wedgeWidth, + height: number = wedgeHeight + ) => { + const normalForce: IForce = { + description: "Normal Force", + magnitude: forceOfGravity.magnitude * Math.cos(Math.atan(height / width)), + directionInDegrees: + 180 - 90 - (Math.atan(height / width) * 180) / Math.PI, + }; + let frictionForce: IForce = { + description: "Static Friction Force", + magnitude: + coefficient * + forceOfGravity.magnitude * + Math.cos(Math.atan(height / width)), + directionInDegrees: 180 - (Math.atan(height / width) * 180) / Math.PI, + }; + // reduce magnitude of friction force if necessary such that block cannot slide up plane + let yForce = -forceOfGravity.magnitude; + yForce += + normalForce.magnitude * + Math.sin((normalForce.directionInDegrees * Math.PI) / 180); + yForce += + frictionForce.magnitude * + Math.sin((frictionForce.directionInDegrees * Math.PI) / 180); + if (yForce > 0) { + frictionForce.magnitude = + (-normalForce.magnitude * + Math.sin((normalForce.directionInDegrees * Math.PI) / 180) + + forceOfGravity.magnitude) / + Math.sin((frictionForce.directionInDegrees * Math.PI) / 180); + } + if (coefficient != 0) { + setStartForces([forceOfGravity, normalForce, frictionForce]); + setUpdatedForces([forceOfGravity, normalForce, frictionForce]); + } else { + setStartForces([forceOfGravity, normalForce]); + setUpdatedForces([forceOfGravity, normalForce]); + } + }; + + // Change wedge height and width and weight position to match new wedge angle + const changeWedgeBasedOnNewAngle = (angle: number) => { + let width = 0; + let height = 0; + if (angle < 50) { + width = 400; + height = Math.tan((angle * Math.PI) / 180) * 400; + setWedgeWidth(width); + setWedgeHeight(height); + } else if (angle < 70) { + width = 200; + height = Math.tan((angle * Math.PI) / 180) * 200; + setWedgeWidth(width); + setWedgeHeight(height); + } else { + width = 100; + height = Math.tan((angle * Math.PI) / 180) * 100; + setWedgeWidth(width); + setWedgeHeight(height); + } + + // update weight position based on updated wedge width/height + let yPos = (width - 50) * Math.tan((angle * Math.PI) / 180); + if (angle < 40) { + yPos += Math.sqrt(angle); + } else if (angle < 58) { + yPos += angle / 2; + } else if (angle < 68) { + yPos += angle; + } else if (angle < 70) { + yPos += angle * 1.3; + } else if (angle < 75) { + yPos += angle * 1.5; + } else if (angle < 78) { + yPos += angle * 2; + } else if (angle < 79) { + yPos += angle * 2.25; + } else if (angle < 80) { + yPos += angle * 2.6; + } else { + yPos += angle * 3; + } + + setStartPosX(Math.round((xMax * 0.5 - 200) * 10) / 10); + setStartPosY(getDisplayYPos(yPos)); + if (mode == "Freeform") { + updateForcesWithFriction( + Number(coefficientOfStaticFriction), + width, + height + ); + } + }; + + // Helper function to go between display and real values + const getDisplayYPos = (yPos: number) => { + return yMax - yPos - 2 * 50 + 5; + }; + + // In review mode, update forces when coefficient of static friction changed + const updateReviewForcesBasedOnCoefficient = (coefficient: number) => { + let theta: number = Number(wedgeAngle); + let index = + selectedQuestion.variablesForQuestionSetup.indexOf("theta - max 45"); + if (index >= 0) { + theta = questionVariables[index]; + } + if (isNaN(theta)) { + return; + } + setReviewGravityMagnitude(forceOfGravity.magnitude); + setReviewGravityAngle(270); + setReviewNormalMagnitude( + forceOfGravity.magnitude * Math.cos((theta * Math.PI) / 180) + ); + setReviewNormalAngle(90 - theta); + let yForce = -forceOfGravity.magnitude; + yForce += + 9.81 * + Math.cos((theta * Math.PI) / 180) * + Math.sin(((90 - theta) * Math.PI) / 180); + yForce += + coefficient * + 9.81 * + Math.cos((theta * Math.PI) / 180) * + Math.sin(((180 - theta) * Math.PI) / 180); + let friction = coefficient * 9.81 * Math.cos((theta * Math.PI) / 180); + if (yForce > 0) { + friction = + (-(forceOfGravity.magnitude * Math.cos((theta * Math.PI) / 180)) * + Math.sin(((90 - theta) * Math.PI) / 180) + + forceOfGravity.magnitude) / + Math.sin(((180 - theta) * Math.PI) / 180); + } + setReviewStaticMagnitude(friction); + setReviewStaticAngle(180 - theta); + }; + + // In review mode, update forces when wedge angle changed + const updateReviewForcesBasedOnAngle = (angle: number) => { + setReviewGravityMagnitude(9.81); + setReviewGravityAngle(270); + setReviewNormalMagnitude(9.81 * Math.cos((Number(angle) * Math.PI) / 180)); + setReviewNormalAngle(90 - angle); + let yForce = -forceOfGravity.magnitude; + yForce += + 9.81 * + Math.cos((Number(angle) * Math.PI) / 180) * + Math.sin(((90 - Number(angle)) * Math.PI) / 180); + yForce += + reviewCoefficient * + 9.81 * + Math.cos((Number(angle) * Math.PI) / 180) * + Math.sin(((180 - Number(angle)) * Math.PI) / 180); + let friction = + reviewCoefficient * 9.81 * Math.cos((Number(angle) * Math.PI) / 180); + if (yForce > 0) { + friction = + (-(9.81 * Math.cos((Number(angle) * Math.PI) / 180)) * + Math.sin(((90 - Number(angle)) * Math.PI) / 180) + + forceOfGravity.magnitude) / + Math.sin(((180 - Number(angle)) * Math.PI) / 180); + } + setReviewStaticMagnitude(friction); + setReviewStaticAngle(180 - angle); + }; + + // Solve for the correct answers to the generated problem + const getAnswersToQuestion = ( + question: QuestionTemplate, + questionVars: number[] + ) => { + const solutions: number[] = []; + + let theta: number = Number(wedgeAngle); + let index = question.variablesForQuestionSetup.indexOf("theta - max 45"); + if (index >= 0) { + theta = questionVars[index]; + } + let muS: number = Number(coefficientOfStaticFriction); + index = question.variablesForQuestionSetup.indexOf( + "coefficient of static friction" + ); + if (index >= 0) { + muS = questionVars[index]; + } + + for (let i = 0; i < question.answerSolutionDescriptions.length; i++) { + const description = question.answerSolutionDescriptions[i]; + if (!isNaN(Number(description))) { + solutions.push(Number(description)); + } else if (description == "solve normal force angle from wedge angle") { + solutions.push(90 - theta); + } else if ( + description == "solve normal force magnitude from wedge angle" + ) { + solutions.push( + forceOfGravity.magnitude * Math.cos((theta / 180) * Math.PI) + ); + } else if ( + description == + "solve static force magnitude from wedge angle given equilibrium" + ) { + let normalForceMagnitude = + forceOfGravity.magnitude * Math.cos((theta / 180) * Math.PI); + let normalForceAngle = 90 - theta; + let frictionForceAngle = 180 - theta; + let frictionForceMagnitude = + (-normalForceMagnitude * + Math.sin((normalForceAngle * Math.PI) / 180) + + 9.81) / + Math.sin((frictionForceAngle * Math.PI) / 180); + solutions.push(frictionForceMagnitude); + } else if ( + description == + "solve static force angle from wedge angle given equilibrium" + ) { + solutions.push(180 - theta); + } else if ( + description == + "solve minimum static coefficient from wedge angle given equilibrium" + ) { + let normalForceMagnitude = + forceOfGravity.magnitude * Math.cos((theta / 180) * Math.PI); + let normalForceAngle = 90 - theta; + let frictionForceAngle = 180 - theta; + let frictionForceMagnitude = + (-normalForceMagnitude * + Math.sin((normalForceAngle * Math.PI) / 180) + + 9.81) / + Math.sin((frictionForceAngle * Math.PI) / 180); + let frictionCoefficient = frictionForceMagnitude / normalForceMagnitude; + solutions.push(frictionCoefficient); + } else if ( + description == + "solve maximum wedge angle from coefficient of static friction given equilibrium" + ) { + solutions.push((Math.atan(muS) * 180) / Math.PI); + } + } + setSelectedSolutions(solutions); + return solutions; + }; + + // In review mode, check if input answers match correct answers and optionally generate alert + const checkAnswers = (showAlert: boolean = true) => { + let error: boolean = false; + let epsilon: number = 0.01; + if (selectedQuestion) { + for (let i = 0; i < selectedQuestion.answerParts.length; i++) { + if (selectedQuestion.answerParts[i] == "force of gravity") { + if ( + Math.abs(reviewGravityMagnitude - selectedSolutions[i]) > epsilon + ) { + error = true; + } + } else if (selectedQuestion.answerParts[i] == "angle of gravity") { + if (Math.abs(reviewGravityAngle - selectedSolutions[i]) > epsilon) { + error = true; + } + } else if (selectedQuestion.answerParts[i] == "normal force") { + if ( + Math.abs(reviewNormalMagnitude - selectedSolutions[i]) > epsilon + ) { + error = true; + } + } else if (selectedQuestion.answerParts[i] == "angle of normal force") { + if (Math.abs(reviewNormalAngle - selectedSolutions[i]) > epsilon) { + error = true; + } + } else if ( + selectedQuestion.answerParts[i] == "force of static friction" + ) { + if ( + Math.abs(reviewStaticMagnitude - selectedSolutions[i]) > epsilon + ) { + error = true; + } + } else if ( + selectedQuestion.answerParts[i] == "angle of static friction" + ) { + if (Math.abs(reviewStaticAngle - selectedSolutions[i]) > epsilon) { + error = true; + } + } else if ( + selectedQuestion.answerParts[i] == "coefficient of static friction" + ) { + if ( + Math.abs( + Number(coefficientOfStaticFriction) - selectedSolutions[i] + ) > epsilon + ) { + error = true; + } + } else if (selectedQuestion.answerParts[i] == "wedge angle") { + if (Math.abs(Number(wedgeAngle) - selectedSolutions[i]) > epsilon) { + error = true; + } + } + } + } + if (showAlert) { + if (!error) { + setSimulationPaused(false); + setTimeout(() => { + setSimulationPaused(true); + }, 3000); + } else { + setSimulationPaused(false); + setTimeout(() => { + setSimulationPaused(true); + }, 3000); + } + } + if (selectedQuestion.goal == "noMovement") { + if (!error) { + setNoMovement(true); + } else { + setNoMovement(false); + } + } + }; + + const resetReviewValuesToDefault = () => { + // Reset all values to default + setReviewGravityMagnitude(0); + setReviewGravityAngle(0); + setReviewNormalMagnitude(0); + setReviewNormalAngle(0); + setReviewStaticMagnitude(0); + setReviewStaticAngle(0); + setCoefficientOfKineticFriction(0); + setSimulationPaused(true); + setAnswerInputFields(<div></div>); + }; + + // In review mode, edit force arrow sketch on mouse movement + const editForce = (element: VectorTemplate) => { + if (!sketching) { + const sketches = forceSketches.filter((sketch) => sketch != element); + setForceSketches(sketches); + setCurrentForceSketch(element); + setSketching(true); + } + }; + + // In review mode, used to delete force arrow sketch on SHIFT+click + const deleteForce = (element: VectorTemplate) => { + if (!sketching) { + const sketches = forceSketches.filter((sketch) => sketch != element); + setForceSketches(sketches); + } + }; + + // In review mode, reset problem variables and generate a new question + const generateNewQuestion = () => { + resetReviewValuesToDefault(); + + const vars: number[] = []; + let question: QuestionTemplate = questions.inclinePlane[0]; + + if (simulationType == "Inclined Plane") { + if (questionNumber == questions.inclinePlane.length - 1) { + setQuestionNumber(0); + } else { + setQuestionNumber(questionNumber + 1); + } + question = questions.inclinePlane[questionNumber]; + + let coefficient = 0; + let wedge = 0; + + for (let i = 0; i < question.variablesForQuestionSetup.length; i++) { + if (question.variablesForQuestionSetup[i] == "theta - max 45") { + let randValue = Math.floor(Math.random() * 44 + 1); + vars.push(randValue); + wedge = randValue; + } else if ( + question.variablesForQuestionSetup[i] == + "coefficient of static friction" + ) { + let randValue = Math.round(Math.random() * 1000) / 1000; + vars.push(randValue); + coefficient = randValue; + } + } + setWedgeAngle(wedge); + changeWedgeBasedOnNewAngle(wedge); + setCoefficientOfStaticFriction(coefficient); + reviewCoefficient = coefficient; + } + let q = ""; + for (let i = 0; i < question.questionSetup.length; i++) { + q += question.questionSetup[i]; + if (i != question.questionSetup.length - 1) { + q += vars[i]; + if (question.variablesForQuestionSetup[i].includes("theta")) { + q += + " degree (≈" + + Math.round((1000 * (vars[i] * Math.PI)) / 180) / 1000 + + " rad)"; + } + } + } + questionVariables = vars; + setSelectedQuestion(question); + setQuestionPartOne(q); + setQuestionPartTwo(question.question); + const answers = getAnswersToQuestion(question, vars); + generateInputFieldsForQuestion(false, question, answers); + }; + + // Generate answerInputFields for new review question + const generateInputFieldsForQuestion = ( + showIcon: boolean = false, + question: QuestionTemplate = selectedQuestion, + answers: number[] = selectedSolutions + ) => { + let answerInput = []; + const d = new Date(); + for (let i = 0; i < question.answerParts.length; i++) { + if (question.answerParts[i] == "force of gravity") { + setReviewGravityMagnitude(0); + answerInput.push( + <div key={i + d.getTime()}> + <InputField + label={ + <p> + F<sub>G</sub> + </p> + } + lowerBound={0} + changeValue={setReviewGravityMagnitude} + step={0.1} + unit={"N"} + upperBound={50} + value={reviewGravityMagnitude} + showIcon={showIcon} + correctValue={answers[i]} + /> + </div> + ); + } else if (question.answerParts[i] == "angle of gravity") { + setReviewGravityAngle(0); + answerInput.push( + <div key={i + d.getTime()}> + <InputField + label={ + <p> + θ<sub>G</sub> + </p> + } + lowerBound={0} + changeValue={setReviewGravityAngle} + step={1} + unit={"°"} + upperBound={360} + value={reviewGravityAngle} + radianEquivalent={true} + showIcon={showIcon} + correctValue={answers[i]} + /> + </div> + ); + } else if (question.answerParts[i] == "normal force") { + setReviewNormalMagnitude(0); + answerInput.push( + <div key={i + d.getTime()}> + <InputField + label={ + <p> + F<sub>N</sub> + </p> + } + lowerBound={0} + changeValue={setReviewNormalMagnitude} + step={0.1} + unit={"N"} + upperBound={50} + value={reviewNormalMagnitude} + showIcon={showIcon} + correctValue={answers[i]} + /> + </div> + ); + } else if (question.answerParts[i] == "angle of normal force") { + setReviewNormalAngle(0); + answerInput.push( + <div key={i + d.getTime()}> + <InputField + label={ + <p> + θ<sub>N</sub> + </p> + } + lowerBound={0} + changeValue={setReviewNormalAngle} + step={1} + unit={"°"} + upperBound={360} + value={reviewNormalAngle} + radianEquivalent={true} + showIcon={showIcon} + correctValue={answers[i]} + /> + </div> + ); + } else if (question.answerParts[i] == "force of static friction") { + setReviewStaticMagnitude(0); + answerInput.push( + <div key={i + d.getTime()}> + <InputField + label={ + <p> + F + <sub> + F<sub>s</sub> + </sub> + </p> + } + lowerBound={0} + changeValue={setReviewStaticMagnitude} + step={0.1} + unit={"N"} + upperBound={50} + value={reviewStaticMagnitude} + showIcon={showIcon} + correctValue={answers[i]} + /> + </div> + ); + } else if (question.answerParts[i] == "angle of static friction") { + setReviewStaticAngle(0); + answerInput.push( + <div key={i + d.getTime()}> + <InputField + label={ + <p> + θ + <sub> + F<sub>s</sub> + </sub> + </p> + } + lowerBound={0} + changeValue={setReviewStaticAngle} + step={1} + unit={"°"} + upperBound={360} + value={reviewStaticAngle} + radianEquivalent={true} + showIcon={showIcon} + correctValue={answers[i]} + /> + </div> + ); + } else if (question.answerParts[i] == "coefficient of static friction") { + updateReviewForcesBasedOnCoefficient(0); + answerInput.push( + <div key={i + d.getTime()}> + <InputField + label={ + <Tooltip + title={ + <React.Fragment> + <Typography color="inherit"> + μ<sub>s</sub> + </Typography> + Coefficient of static friction; between 0 and 1 + </React.Fragment> + } + followCursor + > + <Box> + μ<sub>s</sub> + </Box> + </Tooltip> + } + lowerBound={0} + changeValue={setCoefficientOfStaticFriction} + step={0.1} + unit={""} + upperBound={1} + value={coefficientOfStaticFriction} + effect={updateReviewForcesBasedOnCoefficient} + showIcon={showIcon} + correctValue={answers[i]} + /> + </div> + ); + } else if (question.answerParts[i] == "wedge angle") { + updateReviewForcesBasedOnAngle(0); + answerInput.push( + <div key={i + d.getTime()}> + <InputField + label={ + <Tooltip + title={ + <React.Fragment> + <Typography color="inherit">θ</Typography> + Angle of incline plane from the ground, 0-49 + </React.Fragment> + } + followCursor + > + <Box>θ</Box> + </Tooltip> + } + lowerBound={0} + changeValue={setWedgeAngle} + step={1} + unit={"°"} + upperBound={49} + value={wedgeAngle} + effect={(val: number) => { + changeWedgeBasedOnNewAngle(val); + updateReviewForcesBasedOnAngle(val); + }} + radianEquivalent={true} + showIcon={showIcon} + correctValue={answers[i]} + /> + </div> + ); + } + } + + setAnswerInputFields( + <div + style={{ display: "flex", flexDirection: "column", alignItems: "left" }} + > + {answerInput} + </div> + ); + }; + + // Remove floor and walls from simulation + const removeWalls = () => { + setWallPositions([]); + }; + + // Add floor and walls to simulation + const addWalls = () => { + if (wallPositions.length == 0) { + const walls: IWallProps[] = []; + walls.push({ length: 70, xPos: 0, yPos: 80, angleInDegrees: 0 }); + walls.push({ length: 80, xPos: 0, yPos: 0, angleInDegrees: 90 }); + walls.push({ length: 80, xPos: 69.5, yPos: 0, angleInDegrees: 90 }); + setWallPositions(walls); + } + }; + + // Use effect hook to handle mode/topic change + useEffect(() => { + if (mode == "Freeform") { + setShowForceMagnitudes(true); + if (simulationType == "One Weight") { + addWeight(); + setStartPosY(yMin + 50); + setStartPosX((xMax + xMin - 50) / 2); + setUpdatedForces([forceOfGravity]); + setStartForces([forceOfGravity]); + addWalls(); + setSimulationReset(!simulationReset); + } else if (simulationType == "Two Weights") { + addTwoWeights(); + setStartPosY(yMax - 100); + setStartPosX((xMax + xMin - 200) / 2); + setStartPosY2(yMax - 100); + setStartPosX2((xMax + xMin + 200) / 2); + setUpdatedForces([forceOfGravity]); + setStartForces([forceOfGravity]); + addWalls(); + setSimulationReset(!simulationReset); + // TODO + } else if (simulationType == "Inclined Plane") { + addWedge(); + changeWedgeBasedOnNewAngle(26); + addWalls(); + setStartForces([forceOfGravity]); + updateForcesWithFriction(Number(coefficientOfStaticFriction)); + } else if (simulationType == "Pendulum") { + const length = 300; + const angle = 50; + const x = length * Math.cos(((90 - angle) * Math.PI) / 180); + const y = length * Math.sin(((90 - angle) * Math.PI) / 180); + const xPos = xMax / 2 - x - 50; + const yPos = y - 50 - 5; + addPendulum(); + setStartPosX(xPos); + setStartPosY(yPos); + const mag = 9.81 * Math.cos((50 * Math.PI) / 180); + const forceOfTension: IForce = { + description: "Tension", + magnitude: mag, + directionInDegrees: 90 - angle, + }; + setUpdatedForces([forceOfGravity, forceOfTension]); + setStartForces([forceOfGravity, forceOfTension]); + setPendulumAngle(50); + setPendulumLength(300); + setAdjustPendulumAngle({ angle: 50, length: 300 }); + removeWalls(); + } + } else if (mode == "Review") { + setShowForceMagnitudes(true); + if (simulationType == "Two Weights") { + // TODO + } else if (simulationType == "Inclined Plane") { + addWedge(); + setUpdatedForces([]); + setStartForces([]); + addWalls(); + } + setShowAcceleration(false); + setShowVelocity(false); + setShowForces(true); + generateNewQuestion(); + } else if (mode == "Tutorial") { + setStepNumber(0); + if (simulationType == "One Weight") { + addWeight(); + setStartPosY(yMin + 50); + setStartPosX((xMax + xMin - 50) / 2); + setSelectedTutorial(tutorials.freeWeight); + setSelectedTutorial(tutorials.freeWeight); + setStartForces(getForceFromJSON(tutorials.freeWeight.steps[0].forces)); + setShowForceMagnitudes(tutorials.freeWeight.steps[0].showMagnitude); + addWalls(); + } else if (simulationType == "Two Weights") { + // TODO + } else if (simulationType == "Pendulum") { + const length = 300; + const angle = 30; + const x = length * Math.cos(((90 - angle) * Math.PI) / 180); + const y = length * Math.sin(((90 - angle) * Math.PI) / 180); + const xPos = xMax / 2 - x - 50; + const yPos = y - 50 - 5; + addPendulum(); + setStartPosX(xPos); + setStartPosY(yPos); + setSelectedTutorial(tutorials.pendulum); + setStartForces(getForceFromJSON(tutorials.pendulum.steps[0].forces)); + setShowForceMagnitudes(tutorials.pendulum.steps[0].showMagnitude); + setPendulumAngle(50); + setPendulumLength(300); + setAdjustPendulumAngle({ angle: 30, length: 300 }); + removeWalls(); + } else if (simulationType == "Inclined Plane") { + addWedge(); + setWedgeAngle(26); + changeWedgeBasedOnNewAngle(26); + setSelectedTutorial(tutorials.inclinePlane); + setStartForces( + getForceFromJSON(tutorials.inclinePlane.steps[0].forces) + ); + setShowForceMagnitudes(tutorials.inclinePlane.steps[0].showMagnitude); + addWalls(); + } + setSimulationReset(!simulationReset); + } + }, [simulationType, mode]); + + const [showForceMagnitudes, setShowForceMagnitudes] = useState<boolean>(true); + + const getForceFromJSON = ( + json: { + description: string; + magnitude: number; + directionInDegrees: number; + }[] + ): IForce[] => { + const forces: IForce[] = []; + for (let i = 0; i < json.length; i++) { + const force: IForce = { + description: json[i].description, + magnitude: json[i].magnitude, + directionInDegrees: json[i].directionInDegrees, + }; + forces.push(force); + } + return forces; + }; + + // Use effect hook to handle force change in review mode + useEffect(() => { + if (mode == "Review") { + const forceOfGravityReview: IForce = { + description: "Gravity", + magnitude: reviewGravityMagnitude, + directionInDegrees: reviewGravityAngle, + }; + const normalForceReview: IForce = { + description: "Normal Force", + magnitude: reviewNormalMagnitude, + directionInDegrees: reviewNormalAngle, + }; + const staticFrictionForceReview: IForce = { + description: "Static Friction Force", + magnitude: reviewStaticMagnitude, + directionInDegrees: reviewStaticAngle, + }; + setStartForces([ + forceOfGravityReview, + normalForceReview, + staticFrictionForceReview, + ]); + setUpdatedForces([ + forceOfGravityReview, + normalForceReview, + staticFrictionForceReview, + ]); + } + }, [ + reviewGravityMagnitude, + reviewGravityAngle, + reviewNormalMagnitude, + reviewNormalAngle, + reviewStaticMagnitude, + reviewStaticAngle, + ]); + + // Use effect to add listener for SHIFT key, which determines if sketch force arrow will be edited or deleted on click + useEffect(() => { + document.addEventListener("keydown", (e) => { + if (e.shiftKey) { + setDeleteMode(true); + } + }); + document.addEventListener("keyup", (e) => { + if (e.shiftKey) { + setDeleteMode(false); + } + }); + }, []); + + // Timer for animating the simulation + setInterval(() => { + setTimer(timer + 1); + }, 60); + + return ( + <div> + <div className="mechanicsSimulationContainer"> + <div + className="mechanicsSimulationContentContainer" + onPointerMove={(e) => { + if (sketching) { + const x1 = positionXDisplay + 50; + const y1 = yMax - positionYDisplay - 2 * 50 + 5 + 50; + const x2 = e.clientX; + const y2 = e.clientY; + const height = Math.abs(y1 - y2) + 120; + const width = Math.abs(x1 - x2) + 120; + const top = Math.min(y1, y2) - 60; + const left = Math.min(x1, x2) - 60; + const x1Updated = x1 - left; + const x2Updated = x2 - left; + const y1Updated = y1 - top; + const y2Updated = y2 - top; + setCurrentForceSketch({ + top: top, + left: left, + width: width, + height: height, + x1: x1Updated, + y1: y1Updated, + x2: x2Updated, + y2: y2Updated, + weightX: positionXDisplay, + weightY: positionYDisplay, + }); + } + }} + onPointerDown={(e) => { + if (sketching && currentForceSketch) { + setSketching(false); + const sketches = forceSketches; + sketches.push(currentForceSketch); + setForceSketches(sketches); + setCurrentForceSketch(null); + } + }} + > + <div className="mechanicsSimulationButtonsAndElements"> + <div className="mechanicsSimulationButtons"> + {!simulationPaused && ( + <div + style={{ + position: "fixed", + left: "10vw", + top: "95vh", + width: "50vw", + }} + > + <LinearProgress /> + </div> + )} + <div + style={{ + position: "fixed", + top: 1 + "em", + left: xMin + 12 + "px", + }} + > + <div className="dropdownMenu"> + <select + value={simulationType} + onChange={(event) => { + setSimulationType(event.target.value); + }} + style={{ height: "2em", width: "100%", fontSize: "16px" }} + > + <option value="One Weight">One Weight</option> + <option value="Two Weights">Two Weights</option> + <option value="Inclined Plane">Inclined Plane</option> + <option value="Pendulum">Pendulum</option> + </select> + </div> + </div> + </div> + <div className="mechanicsSimulationElements"> + {showForces && currentForceSketch && simulationPaused && ( + <div + style={{ + position: "fixed", + top: currentForceSketch.top, + left: currentForceSketch.left, + }} + > + <svg + width={currentForceSketch.width + "px"} + height={currentForceSketch.height + "px"} + > + <defs> + <marker + id="sketchArrow" + markerWidth="10" + markerHeight="10" + refX="0" + refY="2" + orient="auto" + markerUnits="strokeWidth" + > + <path d="M0,0 L0,4 L6,2 z" fill={color} /> + </marker> + </defs> + <line + x1={currentForceSketch.x1} + y1={currentForceSketch.y1} + x2={currentForceSketch.x2} + y2={currentForceSketch.y2} + stroke={color} + strokeWidth="10" + markerEnd="url(#sketchArrow)" + /> + </svg> + </div> + )} + {showForces && + forceSketches.length > 0 && + simulationPaused && + forceSketches.map((element: VectorTemplate, index) => { + return ( + <div + key={index} + style={{ + position: "fixed", + top: element.top + (positionYDisplay - element.weightY), + left: + element.left + (positionXDisplay - element.weightX), + }} + > + <svg + width={element.width + "px"} + height={element.height + "px"} + > + <defs> + <marker + id="sketchArrow" + markerWidth="10" + markerHeight="10" + refX="0" + refY="2" + orient="auto" + markerUnits="strokeWidth" + > + <path d="M0,0 L0,4 L6,2 z" fill={color} /> + </marker> + </defs> + <line + x1={element.x1} + y1={element.y1} + x2={element.x2} + y2={element.y2} + stroke={color} + strokeWidth="10" + markerEnd="url(#sketchArrow)" + onClick={() => { + if (deleteMode) { + deleteForce(element); + } else { + editForce(element); + } + }} + /> + </svg> + </div> + ); + })} + {weight && ( + <Weight + adjustPendulumAngle={adjustPendulumAngle} + color={"red"} + displayXPosition={positionXDisplay} + displayXVelocity={velocityXDisplay} + displayYPosition={positionYDisplay} + displayYVelocity={velocityYDisplay} + elasticCollisions={elasticCollisions} + incrementTime={timer} + mass={1} + mode={mode} + noMovement={noMovement} + paused={simulationPaused} + pendulum={pendulum} + pendulumAngle={pendulumAngle} + pendulumLength={pendulumLength} + radius={50} + reset={simulationReset} + showForceMagnitudes={showForceMagnitudes} + setSketching={setSketching} + setDisplayXAcceleration={setAccelerationXDisplay} + setDisplayXPosition={setPositionXDisplay} + setDisplayXVelocity={setVelocityXDisplay} + setDisplayYAcceleration={setAccelerationYDisplay} + setDisplayYPosition={setPositionYDisplay} + setDisplayYVelocity={setVelocityYDisplay} + setPaused={setSimulationPaused} + setPendulumAngle={setPendulumAngle} + setPendulumLength={setPendulumLength} + setStartPendulumAngle={setStartPendulumAngle} + setUpdatedForces={setUpdatedForces} + showAcceleration={showAcceleration} + showForces={showForces} + showVelocity={showVelocity} + startForces={startForces} + startPosX={startPosX} + startPosY={startPosY} + timestepSize={0.002} + updateDisplay={displayChange} + updatedForces={updatedForces} + walls={wallPositions} + wedge={wedge} + wedgeHeight={wedgeHeight} + wedgeWidth={wedgeWidth} + coefficientOfKineticFriction={Number( + coefficientOfKineticFriction + )} + /> + )} + {twoWeights && ( + <Weight + adjustPendulumAngle={adjustPendulumAngle} + color={"blue"} + displayXPosition={positionXDisplay2} + displayXVelocity={velocityXDisplay2} + displayYPosition={positionYDisplay2} + displayYVelocity={velocityYDisplay2} + elasticCollisions={elasticCollisions} + incrementTime={timer} + mass={1} + mode={mode} + noMovement={noMovement} + paused={simulationPaused} + pendulum={pendulum} + pendulumAngle={pendulumAngle} + pendulumLength={pendulumLength} + radius={50} + reset={simulationReset} + showForceMagnitudes={showForceMagnitudes} + setSketching={setSketching} + setDisplayXAcceleration={setAccelerationXDisplay2} + setDisplayXPosition={setPositionXDisplay2} + setDisplayXVelocity={setVelocityXDisplay2} + setDisplayYAcceleration={setAccelerationYDisplay2} + setDisplayYPosition={setPositionYDisplay2} + setDisplayYVelocity={setVelocityYDisplay2} + setPaused={setSimulationPaused} + setPendulumAngle={setPendulumAngle} + setPendulumLength={setPendulumLength} + setStartPendulumAngle={setStartPendulumAngle} + setUpdatedForces={setUpdatedForces} + showAcceleration={showAcceleration} + showForces={showForces} + showVelocity={showVelocity} + startForces={startForces} + startPosX={startPosX2} + startPosY={startPosY2} + timestepSize={0.002} + updateDisplay={displayChange} + updatedForces={updatedForces} + walls={wallPositions} + wedge={wedge} + wedgeHeight={wedgeHeight} + wedgeWidth={wedgeWidth} + coefficientOfKineticFriction={Number( + coefficientOfKineticFriction + )} + /> + )} + {wedge && ( + <Wedge + startWidth={wedgeWidth} + startHeight={wedgeHeight} + startLeft={xMax * 0.5 - 200} + /> + )} + </div> + <div> + {wallPositions.map((element, index) => { + return ( + <Wall + key={index} + length={element.length} + xPos={element.xPos} + yPos={element.yPos} + angleInDegrees={element.angleInDegrees} + /> + ); + })} + </div> + </div> + </div> + <div className="mechanicsSimulationEquationContainer"> + <div className="mechanicsSimulationControls"> + <Stack direction="row" spacing={1}> + {simulationPaused && mode != "Tutorial" && ( + <Tooltip title="Start simulation" followCursor> + <IconButton + onClick={() => { + setSimulationPaused(false); + }} + > + <PlayArrowIcon /> + </IconButton> + </Tooltip> + )} + {!simulationPaused && mode != "Tutorial" && ( + <Tooltip title="Pause simulation" followCursor> + <IconButton + onClick={() => { + setSimulationPaused(true); + }} + > + <PauseIcon /> + </IconButton> + </Tooltip> + )} + {simulationPaused && mode != "Tutorial" && ( + <Tooltip title="Reset simulation" followCursor> + <IconButton + onClick={() => { + setSimulationReset(!simulationReset); + }} + > + <ReplayIcon /> + </IconButton> + </Tooltip> + )} + </Stack> + <div className="dropdownMenu"> + <select + value={mode} + onChange={(event) => { + setMode(event.target.value); + }} + style={{ height: "2em", width: "100%", fontSize: "16px" }} + > + <option value="Freeform">Freeform Mode</option> + <option value="Review">Review Mode</option> + <option value="Tutorial">Tutorial Mode</option> + </select> + </div> + </div> + {mode == "Review" && ( + <div> + {!hintDialogueOpen && ( + <IconButton + onClick={() => { + setHintDialogueOpen(true); + }} + sx={{ + position: "fixed", + left: xMax - 50 + "px", + top: yMin + 14 + "px", + }} + > + <QuestionMarkIcon /> + </IconButton> + )} + <Dialog + maxWidth={"sm"} + fullWidth={true} + open={hintDialogueOpen} + onClose={() => setHintDialogueOpen(false)} + > + <DialogTitle>Hints</DialogTitle> + <DialogContent> + {selectedQuestion.hints.map((hint, index) => { + return ( + <div key={index}> + <DialogContentText> + <details> + <summary> + <b> + Hint {index + 1}: {hint.description} + </b> + </summary> + {hint.content} + </details> + </DialogContentText> + </div> + ); + })} + </DialogContent> + <DialogActions> + <Button + onClick={() => { + setHintDialogueOpen(false); + }} + > + Close + </Button> + </DialogActions> + </Dialog> + <div className="wordProblemBox"> + <div className="question"> + <p>{questionPartOne}</p> + <p>{questionPartTwo}</p> + </div> + <div className="answer">{answerInputFields}</div> + </div> + </div> + )} + {mode == "Tutorial" && ( + <div className="wordProblemBox"> + <div className="question"> + <h2>Problem</h2> + <p>{selectedTutorial.question}</p> + </div> + <div + style={{ + display: "flex", + justifyContent: "spaceBetween", + width: "100%", + }} + > + <IconButton + onClick={() => { + let step = stepNumber - 1; + step = Math.max(step, 0); + step = Math.min(step, selectedTutorial.steps.length - 1); + setStepNumber(step); + setStartForces( + getForceFromJSON(selectedTutorial.steps[step].forces) + ); + setUpdatedForces( + getForceFromJSON(selectedTutorial.steps[step].forces) + ); + setShowForceMagnitudes( + selectedTutorial.steps[step].showMagnitude + ); + }} + disabled={stepNumber == 0} + > + <ArrowLeftIcon /> + </IconButton> + <div> + <h3> + Step {stepNumber + 1}:{" "} + {selectedTutorial.steps[stepNumber].description} + </h3> + <p>{selectedTutorial.steps[stepNumber].content}</p> + </div> + <IconButton + onClick={() => { + let step = stepNumber + 1; + step = Math.max(step, 0); + step = Math.min(step, selectedTutorial.steps.length - 1); + setStepNumber(step); + setStartForces( + getForceFromJSON(selectedTutorial.steps[step].forces) + ); + setUpdatedForces( + getForceFromJSON(selectedTutorial.steps[step].forces) + ); + setShowForceMagnitudes( + selectedTutorial.steps[step].showMagnitude + ); + }} + disabled={stepNumber == selectedTutorial.steps.length - 1} + > + <ArrowRightIcon /> + </IconButton> + </div> + <div> + <p>Resources</p> + {simulationType == "One Weight" && ( + <ul> + <li> + <a + href="https://www.khanacademy.org/science/physics/one-dimensional-motion" + target="_blank" + rel="noreferrer" + style={{ color: "blue", textDecoration: "underline" }} + > + Khan Academy - One Dimensional Motion + </a> + </li> + <li> + <a + href="https://www.khanacademy.org/science/physics/two-dimensional-motion" + target="_blank" + rel="noreferrer" + style={{ color: "blue", textDecoration: "underline" }} + > + Khan Academy - Two Dimensional Motion + </a> + </li> + </ul> + )} + {simulationType == "Inclined Plane" && ( + <ul> + <li> + <a + href="https://www.khanacademy.org/science/physics/forces-newtons-laws#normal-contact-force" + target="_blank" + rel="noreferrer" + style={{ color: "blue", textDecoration: "underline" }} + > + Khan Academy - Normal Force + </a> + </li> + <li> + <a + href="https://www.khanacademy.org/science/physics/forces-newtons-laws#inclined-planes-friction" + target="_blank" + rel="noreferrer" + style={{ color: "blue", textDecoration: "underline" }} + > + Khan Academy - Inclined Planes + </a> + </li> + </ul> + )} + {simulationType == "Pendulum" && ( + <ul> + <li> + <a + href="https://www.khanacademy.org/science/physics/forces-newtons-laws#tension-tutorial" + target="_blank" + rel="noreferrer" + style={{ color: "blue", textDecoration: "underline" }} + > + Khan Academy - Tension + </a> + </li> + </ul> + )} + </div> + </div> + )} + {mode == "Review" && ( + <div + style={{ + display: "flex", + justifyContent: "space-between", + marginTop: "10px", + }} + > + <p + style={{ + color: "blue", + textDecoration: "underline", + cursor: "pointer", + }} + onClick={() => setMode("Tutorial")} + > + {" "} + Go to walkthrough{" "} + </p> + <div style={{ display: "flex", flexDirection: "column" }}> + <Button + onClick={() => { + setSimulationReset(!simulationReset); + checkAnswers(); + generateInputFieldsForQuestion(true); + }} + variant="outlined" + > + <Typography>Submit</Typography> + </Button> + <Button + onClick={() => generateNewQuestion()} + variant="outlined" + > + <Typography>New question</Typography> + </Button> + </div> + </div> + )} + + {mode == "Freeform" && ( + <div> + <FormControl component="fieldset"> + <FormGroup> + {!wedge && !pendulum && ( + <FormControlLabel + control={ + <Checkbox + value={elasticCollisions} + onChange={() => + setElasticCollisions(!elasticCollisions) + } + /> + } + label="Make collisions elastic" + labelPlacement="start" + /> + )} + {!wedge && !pendulum && <Divider />} + <FormControlLabel + control={ + <Checkbox + value={showForces} + onChange={() => setShowForces(!showForces)} + defaultChecked + /> + } + label="Show force vectors" + labelPlacement="start" + /> + <FormControlLabel + control={ + <Checkbox + value={showAcceleration} + onChange={() => setShowAcceleration(!showAcceleration)} + /> + } + label="Show acceleration vector" + labelPlacement="start" + /> + <FormControlLabel + control={ + <Checkbox + value={showVelocity} + onChange={() => setShowVelocity(!showVelocity)} + /> + } + label="Show velocity vector" + labelPlacement="start" + /> + </FormGroup> + </FormControl> + {wedge && simulationPaused && ( + <div> + <InputField + label={ + <Tooltip + title={ + <React.Fragment> + <Typography color="inherit">θ</Typography> + Angle of incline plane from the ground, 0-49 + </React.Fragment> + } + followCursor + > + <Box>θ</Box> + </Tooltip> + } + lowerBound={0} + changeValue={setWedgeAngle} + step={1} + unit={"°"} + upperBound={49} + value={wedgeAngle} + effect={changeWedgeBasedOnNewAngle} + radianEquivalent={true} + mode={"Freeform"} + /> + <InputField + label={ + <Tooltip + title={ + <React.Fragment> + <Typography color="inherit"> + μ<sub>s</sub> + </Typography> + Coefficient of static friction, between 0 and 1 + </React.Fragment> + } + followCursor + > + <Box> + μ<sub>s</sub> + </Box> + </Tooltip> + } + lowerBound={0} + changeValue={setCoefficientOfStaticFriction} + step={0.1} + unit={""} + upperBound={1} + value={coefficientOfStaticFriction} + effect={updateForcesWithFriction} + mode={"Freeform"} + /> + <InputField + label={ + <Tooltip + title={ + <React.Fragment> + <Typography color="inherit"> + μ<sub>k</sub> + </Typography> + Coefficient of kinetic friction, between 0 and + coefficient of static friction + </React.Fragment> + } + followCursor + > + <Box> + μ<sub>k</sub> + </Box> + </Tooltip> + } + lowerBound={0} + changeValue={setCoefficientOfKineticFriction} + step={0.1} + unit={""} + upperBound={Number(coefficientOfStaticFriction)} + value={coefficientOfKineticFriction} + mode={"Freeform"} + /> + </div> + )} + {wedge && !simulationPaused && ( + <Typography> + θ: {Math.round(Number(wedgeAngle) * 100) / 100}° ≈{" "} + {Math.round(((Number(wedgeAngle) * Math.PI) / 180) * 100) / + 100}{" "} + rad + <br /> + μ <sub>s</sub>: {coefficientOfStaticFriction} + <br /> + μ <sub>k</sub>: {coefficientOfKineticFriction} + </Typography> + )} + {pendulum && !simulationPaused && ( + <Typography> + θ: {Math.round(pendulumAngle * 100) / 100}° ≈{" "} + {Math.round(((pendulumAngle * Math.PI) / 180) * 100) / 100}{" "} + rad + </Typography> + )} + {pendulum && simulationPaused && ( + <div> + <InputField + label={ + <Tooltip + title={ + <React.Fragment> + <Typography color="inherit">θ</Typography> + Pendulum angle offest from equilibrium + </React.Fragment> + } + followCursor + > + <Box>θ</Box> + </Tooltip> + } + lowerBound={0} + changeValue={setPendulumAngle} + step={1} + unit={"°"} + upperBound={59} + value={pendulumAngle} + effect={(value) => { + if (pendulum) { + const mag = + 1 * 9.81 * Math.cos((value * Math.PI) / 180); + + const forceOfTension: IForce = { + description: "Tension", + magnitude: mag, + directionInDegrees: 90 - value, + }; + setUpdatedForces([forceOfGravity, forceOfTension]); + setAdjustPendulumAngle({ + angle: value, + length: pendulumLength, + }); + } + }} + radianEquivalent={true} + mode={"Freeform"} + /> + <InputField + label={ + <Tooltip + title={ + <React.Fragment> + <Typography color="inherit">Length</Typography> + Pendulum rod length + </React.Fragment> + } + followCursor + > + <Box>Length</Box> + </Tooltip> + } + lowerBound={0} + changeValue={setPendulumLength} + step={1} + unit={"m"} + upperBound={400} + value={pendulumLength} + effect={(value) => { + if (pendulum) { + setAdjustPendulumAngle({ + angle: pendulumAngle, + length: value, + }); + } + }} + radianEquivalent={false} + mode={"Freeform"} + /> + </div> + )} + </div> + )} + <div className="mechanicsSimulationEquation"> + {mode == "Freeform" && twoWeights && <p>Red Weight</p>} + {mode == "Freeform" && weight && ( + <table> + <tbody> + <tr> + <td> </td> + <td>X</td> + <td>Y</td> + </tr> + <tr> + <td + style={{ cursor: "help" }} + onClick={() => { + window.open( + "https://www.khanacademy.org/science/physics/two-dimensional-motion" + ); + }} + > + <Tooltip + title={ + <React.Fragment> + <Typography color="inherit">Position</Typography> + Equation: x<sub>1</sub> + =x + <sub>0</sub> + +v + <sub>0</sub> + t+0.5at + <sup>2</sup> + <br /> + Units: m + </React.Fragment> + } + followCursor + > + <Box>Position</Box> + </Tooltip> + </td> + <td> + {(!simulationPaused || wedge) && ( + <p style={{ cursor: "default" }}> + {positionXDisplay} m + </p> + )}{" "} + {simulationPaused && !wedge && ( + <InputField + lowerBound={0} + changeValue={setPositionXDisplay} + step={1} + unit={"m"} + upperBound={xMax} + value={positionXDisplay} + effect={(value) => { + setDisplayChange({ + xDisplay: value, + yDisplay: positionYDisplay, + }); + }} + small={true} + mode={"Freeform"} + /> + )}{" "} + </td> + <td> + {(!simulationPaused || wedge) && ( + <p style={{ cursor: "default" }}> + {positionYDisplay} m + </p> + )}{" "} + {simulationPaused && !wedge && ( + <InputField + lowerBound={0} + changeValue={setPositionYDisplay} + step={1} + unit={"m"} + upperBound={yMax} + value={positionYDisplay} + effect={(value) => { + setDisplayChange({ + xDisplay: positionXDisplay, + yDisplay: value, + }); + }} + small={true} + mode={"Freeform"} + /> + )}{" "} + </td> + </tr> + <tr> + <td + style={{ cursor: "help" }} + onClick={() => { + window.open( + "https://www.khanacademy.org/science/physics/two-dimensional-motion" + ); + }} + > + <Tooltip + title={ + <React.Fragment> + <Typography color="inherit">Velocity</Typography> + Equation: v<sub>1</sub> + =v + <sub>0</sub> + +at + <br /> + Units: m/s + </React.Fragment> + } + followCursor + > + <Box>Velocity</Box> + </Tooltip> + </td> + <td> + {(!simulationPaused || pendulum || wedge) && ( + <p style={{ cursor: "default" }}> + {velocityXDisplay} m/s + </p> + )}{" "} + {simulationPaused && !pendulum && !wedge && ( + <InputField + lowerBound={-50} + changeValue={setVelocityXDisplay} + step={1} + unit={"m/s"} + upperBound={50} + value={velocityXDisplay} + effect={(value) => + setDisplayChange({ + xDisplay: positionXDisplay, + yDisplay: positionYDisplay, + }) + } + small={true} + mode={"Freeform"} + /> + )}{" "} + </td> + <td> + {(!simulationPaused || pendulum || wedge) && ( + <p style={{ cursor: "default" }}> + {velocityYDisplay} m/s + </p> + )}{" "} + {simulationPaused && !pendulum && !wedge && ( + <InputField + lowerBound={-50} + changeValue={setVelocityYDisplay} + step={1} + unit={"m/s"} + upperBound={50} + value={velocityYDisplay} + effect={(value) => + setDisplayChange({ + xDisplay: positionXDisplay, + yDisplay: positionYDisplay, + }) + } + small={true} + mode={"Freeform"} + /> + )}{" "} + </td> + </tr> + <tr> + <td + style={{ cursor: "help" }} + onClick={() => { + window.open( + "https://www.khanacademy.org/science/physics/two-dimensional-motion" + ); + }} + > + <Tooltip + title={ + <React.Fragment> + <Typography color="inherit"> + Acceleration + </Typography> + Equation: a=F/m + <br /> + Units: m/s + <sup>2</sup> + </React.Fragment> + } + followCursor + > + <Box>Acceleration</Box> + </Tooltip> + </td> + <td style={{ cursor: "default" }}> + {accelerationXDisplay} m/s<sup>2</sup> + </td> + <td style={{ cursor: "default" }}> + {accelerationYDisplay} m/s<sup>2</sup> + </td> + </tr> + </tbody> + </table> + )} + </div> + {/* {mode == "Freeform" && + simulationElements.length > 0 && + simulationElements[0].pendulum && ( + <div className="mechanicsSimulationEquation"> + <table> + <tbody> + <tr> + <td> </td> + <td>Value</td> + </tr> + <tr> + <td>Potential Energy</td> + <td> + {Math.round( + pendulumLength * + (1 - Math.cos(pendulumAngle)) * + 9.81 * + 10 + ) / 10}{" "} + J + </td> + </tr> + <tr> + <td>Kinetic Energy</td> + <td> + {Math.round( + (Math.round( + pendulumLength * + (1 - Math.cos(startPendulumAngle)) * + 9.81 * + 10 + ) / + 10 - + Math.round( + pendulumLength * + (1 - Math.cos(pendulumAngle)) * + 9.81 * + 10 + ) / + 10) * + 10 + ) / 10}{" "} + J + </td> + </tr> + <tr> + <td> + <b>Total Energy</b> + </td> + <td> + {Math.round( + pendulumLength * + (1 - Math.cos(startPendulumAngle)) * + 9.81 * + 10 + ) / 10}{" "} + J + </td> + </tr> + </tbody> + </table> + </div> + )}*/} + </div> + </div> + <CoordinateSystem top={window.innerHeight - 120} right={xMin + 90} /> + </div> + ); +} + +export default App; diff --git a/src/client/views/nodes/PhysicsSimulationBox.scss b/src/client/views/nodes/PhysicsSimulationBox.scss index 2eb6e6ff0..f756d59fc 100644 --- a/src/client/views/nodes/PhysicsSimulationBox.scss +++ b/src/client/views/nodes/PhysicsSimulationBox.scss @@ -1,3 +1,69 @@ -.physicsSimulationContainer { - -}
\ No newline at end of file +* { + box-sizing: border-box; + font-size: 14px; +} + +.mechanicsSimulationContainer { + height: 100vh; + width: 100vw; + display: flex; + + .mechanicsSimulationContentContainer { + width: 70%; + + .mechanicsSimulationButtons { + display: flex; + justify-content: space-between; + } + } + + .mechanicsSimulationEquationContainer { + width: 30%; + padding: 1em; + display: flex; + flex-direction: column; + + .mechanicsSimulationControls { + display: flex; + justify-content: space-between; + } + + .slider { + margin-top: 0.5em; + } + } +} + +.coordinateSystem { + z-index: -100; +} + +th, +td { + border-collapse: collapse; + padding: 1em; +} + +table { + min-width: 300px; +} + +tr:nth-child(even) { + background-color: #d6eeee; +} + +button { + z-index: 5000; +} + +.wordProblemBox { + border-style: solid; + border-color: black; + border-width: 1px; + margin-top: 10px; + padding: 10px; +} + +.answer-inactive { + pointer-events: none; +} diff --git a/src/client/views/nodes/PhysicsSimulationBox.tsx b/src/client/views/nodes/PhysicsSimulationBox.tsx index b16bccdde..4a68ba5aa 100644 --- a/src/client/views/nodes/PhysicsSimulationBox.tsx +++ b/src/client/views/nodes/PhysicsSimulationBox.tsx @@ -3,6 +3,7 @@ import { FieldView, FieldViewProps } from './FieldView'; import React = require('react'); import { ViewBoxAnnotatableComponent } from '../DocComponent'; import { observer } from 'mobx-react'; +import App from './PhysicsSimulationApp'; export interface IForce { description: string; @@ -18,65 +19,17 @@ export interface IWallProps { @observer export default class PhysicsSimulationBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { - forceOfGravity: IForce = { - description: "Gravity", - magnitude: 9.81, - directionInDegrees: 270, -}; - - // Logic for Dash integration public static LayoutString(fieldKey: string) { return FieldView.LayoutString(PhysicsSimulationBox, fieldKey); } constructor(props: any) { super(props); - this.state = { - timer: 0, - weight: true, - pendulum: false, - wedge: false, - startPosX: 0, - startPosY: 0, - showVelocity: false, - showAcceleration: false, - showForces: false, - elasticCollisions: false, - updatedForces: [this.forceOfGravity], - wallPositions: [] - } } - // Add one weight to the simulation - addWeight = () => { - this.setState({weight: true}); - this.setState({wedge: false}); - this.setState({pendulum: false}); - }; - -// Remove floor and walls from simulation -removeWalls = () => { - this.setState({wallPositions: []}); -}; - -// Add floor and walls to simulation -addWalls = () => { - const walls: IWallProps[] = []; - walls.push({ length: 70, xPos: 0, yPos: 80, angleInDegrees: 0 }); - walls.push({ length: 80, xPos: 0, yPos: 0, angleInDegrees: 90 }); - walls.push({ length: 80, xPos: 69.5, yPos: 0, angleInDegrees: 90 }); - this.setState({wallPositions: walls}); -}; - - // Timer for animating the simulation - // setInterval(() => { - // const time = this.timer ?? 0 - // this.setState({timer: time+1}); - // }, 60); - render () { return ( <div className = "physicsSimulationContainer"> - - </div> + <App/> + </div> ); } }
\ No newline at end of file diff --git a/src/client/views/nodes/PhysicsSimulationWall.tsx b/src/client/views/nodes/PhysicsSimulationWall.tsx new file mode 100644 index 000000000..c63538cc0 --- /dev/null +++ b/src/client/views/nodes/PhysicsSimulationWall.tsx @@ -0,0 +1,35 @@ +import { useState, useEffect } from "react"; +import "./Weight.scss"; + +export interface Force { + magnitude: number; + directionInDegrees: number; +} +export interface IWallProps { + length: number; + xPos: number; + yPos: number; + angleInDegrees: number; +} + +export const Wall = (props: IWallProps) => { + const { length, xPos, yPos, angleInDegrees } = props; + + let wallStyle = { + width: length + "%", + height: 0.5 + "vw", + position: "absolute" as "absolute", + left: xPos + "%", + top: yPos + "%", + backgroundColor: "#6c7b8b", + zIndex: -1000, + margin: 0, + padding: 0, + }; + if (angleInDegrees != 0) { + wallStyle.width = 0.5 + "vw"; + wallStyle.height = length + "%"; + } + + return <div style={wallStyle}></div>; +}; diff --git a/src/client/views/nodes/PhysicsSimulationWedge.tsx b/src/client/views/nodes/PhysicsSimulationWedge.tsx new file mode 100644 index 000000000..af01c1c51 --- /dev/null +++ b/src/client/views/nodes/PhysicsSimulationWedge.tsx @@ -0,0 +1,64 @@ +import { useState, useEffect, useCallback } from "react"; +import "./Wedge.scss"; + +export interface IWedgeProps { + startHeight: number; + startWidth: number; + startLeft: number; +} + +export const Wedge = (props: IWedgeProps) => { + const { startHeight, startWidth, startLeft } = props; + + const [angleInRadians, setAngleInRadians] = useState( + Math.atan(startHeight / startWidth) + ); + const [left, setLeft] = useState(startLeft); + const [coordinates, setCoordinates] = useState(""); + + const color = "#deb887"; + + useEffect(() => { + const coordinatePair1 = + Math.round(left) + "," + Math.round(window.innerHeight * 0.8) + " "; + const coordinatePair2 = + Math.round(left + startWidth) + + "," + + Math.round(window.innerHeight * 0.8) + + " "; + const coordinatePair3 = + Math.round(left) + + "," + + Math.round(window.innerHeight * 0.8 - startHeight); + const coord = coordinatePair1 + coordinatePair2 + coordinatePair3; + setCoordinates(coord); + }, [left, startWidth, startHeight]); + + useEffect(() => { + setAngleInRadians(Math.atan(startHeight / startWidth)); + }, [startWidth, startHeight]); + + return ( + <div> + <div style={{ position: "absolute", left: "0", top: "0", zIndex: -5 }}> + <svg + width={window.innerWidth * 0.7 + "px"} + height={window.innerHeight * 0.8 + "px"} + > + <polygon points={coordinates} style={{ fill: "burlywood" }} /> + </svg> + </div> + + <p + style={{ + position: "absolute", + zIndex: 500, + left: Math.round(left + startWidth - 80) + "px", + top: Math.round(window.innerHeight * 0.8 - 40) + "px", + }} + > + {Math.round(((angleInRadians * 180) / Math.PI) * 100) / 100}° + </p> + </div> + ); +}; diff --git a/src/client/views/nodes/PhysicsSimulationWeight.tsx b/src/client/views/nodes/PhysicsSimulationWeight.tsx new file mode 100644 index 000000000..227f20901 --- /dev/null +++ b/src/client/views/nodes/PhysicsSimulationWeight.tsx @@ -0,0 +1,913 @@ +import { InputAdornment, TextField } from "@mui/material"; +import { useEffect, useState } from "react"; +import { IWallProps } from "./PhysicsSimulationWall"; +import { Wedge } from "./PhysicsSimulationWedge"; + +export interface IForce { + description: string; + magnitude: number; + directionInDegrees: number; +} +export interface IWeightProps { + adjustPendulumAngle: { angle: number; length: number }; + color: string; + displayXPosition: number; + displayYPosition: number; + displayXVelocity: number; + displayYVelocity: number; + elasticCollisions: boolean; + startForces: IForce[]; + incrementTime: number; + mass: number; + paused: boolean; + pendulum: boolean; + pendulumLength: number; + wedge: boolean; + radius: number; + reset: boolean; + setDisplayXAcceleration: (val: number) => any; + setDisplayXPosition: (val: number) => any; + setDisplayXVelocity: (val: number) => any; + setDisplayYAcceleration: (val: number) => any; + setDisplayYPosition: (val: number) => any; + setDisplayYVelocity: (val: number) => any; + setPaused: (bool: boolean) => any; + setPendulumAngle: (val: number) => any; + setPendulumLength: (val: number) => any; + setStartPendulumAngle: (val: number) => any; + showAcceleration: boolean; + mode: string; + noMovement: boolean; + pendulumAngle: number; + setSketching: (val: boolean) => any; + showForces: boolean; + showForceMagnitudes: boolean; + showVelocity: boolean; + startPosX: number; + startPosY: number; + startVelX?: number; + startVelY?: number; + timestepSize: number; + updateDisplay: { xDisplay: number; yDisplay: number }; + updatedForces: IForce[]; + setUpdatedForces: (val: IForce[]) => any; + walls: IWallProps[]; + coefficientOfKineticFriction: number; + wedgeWidth: number; + wedgeHeight: number; +} + +export const Weight = (props: IWeightProps) => { + const { + adjustPendulumAngle, + color, + displayXPosition, + displayYPosition, + displayXVelocity, + displayYVelocity, + elasticCollisions, + startForces, + incrementTime, + mass, + paused, + pendulum, + pendulumLength, + wedge, + radius, + mode, + noMovement, + pendulumAngle, + reset, + setSketching, + setDisplayXAcceleration, + setDisplayXPosition, + setDisplayXVelocity, + setDisplayYAcceleration, + setDisplayYPosition, + setDisplayYVelocity, + setPaused, + setPendulumAngle, + setPendulumLength, + setStartPendulumAngle, + showAcceleration, + showForces, + showForceMagnitudes, + showVelocity, + startPosX, + startPosY, + startVelX, + startVelY, + timestepSize, + updateDisplay, + updatedForces, + setUpdatedForces, + walls, + coefficientOfKineticFriction, + wedgeWidth, + wedgeHeight, + } = props; + + // Constants + const draggable = !wedge && mode == "Freeform"; + const epsilon = 0.0001; + + const forceOfGravity: IForce = { + description: "Gravity", + magnitude: mass * 9.81, + directionInDegrees: 270, + }; + const xMax = window.innerWidth * 0.7; + const xMin = 0; + const yMax = window.innerHeight * 0.8; + const yMin = 0; + + // State hooks + const [dragging, setDragging] = useState(false); + const [kineticFriction, setKineticFriction] = useState(false); + const [updatedStartPosX, setUpdatedStartPosX] = useState(startPosX); + const [updatedStartPosY, setUpdatedStartPosY] = useState(startPosY); + const [xPosition, setXPosition] = useState(startPosX); + const [xVelocity, setXVelocity] = useState(startVelX ?? 0); + const [yPosition, setYPosition] = useState(startPosY); + const [yVelocity, setYVelocity] = useState(startVelY ?? 0); + + // Helper function to go between display and real values + const getDisplayYPos = (yPos: number) => { + return yMax - yPos - 2 * radius + 5; + }; + const getYPosFromDisplay = (yDisplay: number) => { + return yMax - yDisplay - 2 * radius + 5; + }; + + // Set display values based on real values + const setYPosDisplay = (yPos: number) => { + const displayPos = getDisplayYPos(yPos); + setDisplayYPosition(Math.round(displayPos * 100) / 100); + }; + const setXPosDisplay = (xPos: number) => { + setDisplayXPosition(Math.round(xPos * 100) / 100); + }; + const setYVelDisplay = (yVel: number) => { + setDisplayYVelocity((-1 * Math.round(yVel * 100)) / 100); + }; + const setXVelDisplay = (xVel: number) => { + setDisplayXVelocity(Math.round(xVel * 100) / 100); + }; + + const setDisplayValues = ( + xPos: number = xPosition, + yPos: number = yPosition, + xVel: number = xVelocity, + yVel: number = yVelocity + ) => { + setYPosDisplay(yPos); + setXPosDisplay(xPos); + setYVelDisplay(yVel); + setXVelDisplay(xVel); + setDisplayYAcceleration( + (-1 * Math.round(getNewAccelerationY(updatedForces) * 100)) / 100 + ); + setDisplayXAcceleration( + Math.round(getNewAccelerationX(updatedForces) * 100) / 100 + ); + }; + + // When display values updated by user, update real values + useEffect(() => { + if (updateDisplay.xDisplay != xPosition) { + let x = updateDisplay.xDisplay; + x = Math.max(0, x); + x = Math.min(x, xMax - 2 * radius); + setUpdatedStartPosX(x); + setXPosition(x); + setDisplayXPosition(x); + } + + if (updateDisplay.yDisplay != getDisplayYPos(yPosition)) { + let y = updateDisplay.yDisplay; + y = Math.max(0, y); + y = Math.min(y, yMax - 2 * radius); + setDisplayYPosition(y); + let coordinatePosition = getYPosFromDisplay(y); + setUpdatedStartPosY(coordinatePosition); + setYPosition(coordinatePosition); + } + + if (displayXVelocity != xVelocity) { + let x = displayXVelocity; + setXVelocity(x); + setDisplayXVelocity(x); + } + + if (displayYVelocity != -yVelocity) { + let y = displayYVelocity; + setYVelocity(-y); + setDisplayYVelocity(y); + } + }, [updateDisplay]); + + // Check for collisions and update + useEffect(() => { + if (!paused && !noMovement) { + let collisions = false; + if (!pendulum) { + const collisionsWithGround = checkForCollisionsWithGround(); + const collisionsWithWalls = checkForCollisionsWithWall(); + collisions = collisionsWithGround || collisionsWithWalls; + } + if (!collisions) { + update(); + } + setDisplayValues(); + } + }, [incrementTime]); + + useEffect(() => { + resetEverything(); + }, [reset]); + + useEffect(() => { + setXVelocity(startVelX ?? 0); + setYVelocity(startVelY ?? 0); + setDisplayValues(); + }, [startForces]); + + const resetEverything = () => { + setKineticFriction(false); + setXPosition(updatedStartPosX); + setYPosition(updatedStartPosY); + setXVelocity(startVelX ?? 0); + setYVelocity(startVelY ?? 0); + setUpdatedForces(startForces); + setDisplayValues(); + }; + + // Change pendulum angle based on input field + useEffect(() => { + let length = adjustPendulumAngle.length; + const x = + length * Math.cos(((90 - adjustPendulumAngle.angle) * Math.PI) / 180); + const y = + length * Math.sin(((90 - adjustPendulumAngle.angle) * Math.PI) / 180); + const xPos = xMax / 2 - x - radius; + const yPos = y - radius - 5; + setXPosition(xPos); + setYPosition(yPos); + setUpdatedStartPosX(xPos); + setUpdatedStartPosY(yPos); + setPendulumAngle(adjustPendulumAngle.angle); + setPendulumLength(adjustPendulumAngle.length); + }, [adjustPendulumAngle]); + + const getNewAccelerationX = (forceList: IForce[]) => { + let newXAcc = 0; + forceList.forEach((force) => { + newXAcc += + (force.magnitude * + Math.cos((force.directionInDegrees * Math.PI) / 180)) / + mass; + }); + return newXAcc; + }; + + const getNewAccelerationY = (forceList: IForce[]) => { + let newYAcc = 0; + forceList.forEach((force) => { + newYAcc += + (-1 * + (force.magnitude * + Math.sin((force.directionInDegrees * Math.PI) / 180))) / + mass; + }); + return newYAcc; + }; + + const getNewForces = ( + xPos: number, + yPos: number, + xVel: number, + yVel: number + ) => { + if (!pendulum) { + return updatedForces; + } + const x = xMax / 2 - xPos - radius; + const y = yPos + radius + 5; + let angle = (Math.atan(y / x) * 180) / Math.PI; + if (angle < 0) { + angle += 180; + } + let oppositeAngle = 90 - angle; + if (oppositeAngle < 0) { + oppositeAngle = 90 - (180 - angle); + } + + const pendulumLength = Math.sqrt(x * x + y * y); + setPendulumAngle(oppositeAngle); + setPendulumLength(Math.sqrt(x * x + y * y)); + + const mag = + mass * 9.81 * Math.cos((oppositeAngle * Math.PI) / 180) + + (mass * (xVel * xVel + yVel * yVel)) / pendulumLength; + + const forceOfTension: IForce = { + description: "Tension", + magnitude: mag, + directionInDegrees: angle, + }; + + return [forceOfGravity, forceOfTension]; + }; + + const getNewPosition = (pos: number, vel: number) => { + return pos + vel * timestepSize; + }; + + const getNewVelocity = (vel: number, acc: number) => { + return vel + acc * timestepSize; + }; + + const checkForCollisionsWithWall = () => { + let collision = false; + const minX = xPosition; + const maxX = xPosition + 2 * radius; + const containerWidth = window.innerWidth; + if (xVelocity != 0) { + walls.forEach((wall) => { + if (wall.angleInDegrees == 90) { + const wallX = (wall.xPos / 100) * window.innerWidth; + if (wall.xPos < 0.35) { + if (minX <= wallX) { + if (elasticCollisions) { + setXVelocity(-xVelocity); + } else { + setXVelocity(0); + setXPosition(wallX + 5); + } + collision = true; + } + } else { + if (maxX >= wallX) { + if (elasticCollisions) { + setXVelocity(-xVelocity); + } else { + setXVelocity(0); + setXPosition(wallX - 2 * radius + 5); + } + collision = true; + } + } + } + }); + } + return collision; + }; + + const checkForCollisionsWithGround = () => { + let collision = false; + const maxY = yPosition + 2 * radius; + if (yVelocity > 0) { + walls.forEach((wall) => { + if (wall.angleInDegrees == 0) { + const groundY = (wall.yPos / 100) * window.innerHeight; + if (maxY >= groundY) { + if (elasticCollisions) { + setYVelocity(-yVelocity); + } else { + setYVelocity(0); + setYPosition(groundY - 2 * radius + 5); + const forceOfGravity: IForce = { + description: "Gravity", + magnitude: 9.81 * mass, + directionInDegrees: 270, + }; + const normalForce: IForce = { + description: "Normal force", + magnitude: 9.81 * mass, + directionInDegrees: wall.angleInDegrees + 90, + }; + setUpdatedForces([forceOfGravity, normalForce]); + } + collision = true; + } + } + }); + } + return collision; + }; + + useEffect(() => { + if (wedge && xVelocity != 0 && mode != "Review" && !kineticFriction) { + setKineticFriction(true); + //switch from static to kinetic friction + const normalForce: IForce = { + description: "Normal Force", + magnitude: + forceOfGravity.magnitude * + Math.cos(Math.atan(wedgeHeight / wedgeWidth)), + directionInDegrees: + 180 - 90 - (Math.atan(wedgeHeight / wedgeWidth) * 180) / Math.PI, + }; + let frictionForce: IForce = { + description: "Kinetic Friction Force", + magnitude: + coefficientOfKineticFriction * + forceOfGravity.magnitude * + Math.cos(Math.atan(wedgeHeight / wedgeWidth)), + directionInDegrees: + 180 - (Math.atan(wedgeHeight / wedgeWidth) * 180) / Math.PI, + }; + // reduce magnitude of friction force if necessary such that block cannot slide up plane + let yForce = -forceOfGravity.magnitude; + yForce += + normalForce.magnitude * + Math.sin((normalForce.directionInDegrees * Math.PI) / 180); + yForce += + frictionForce.magnitude * + Math.sin((frictionForce.directionInDegrees * Math.PI) / 180); + if (yForce > 0) { + frictionForce.magnitude = + (-normalForce.magnitude * + Math.sin((normalForce.directionInDegrees * Math.PI) / 180) + + forceOfGravity.magnitude) / + Math.sin((frictionForce.directionInDegrees * Math.PI) / 180); + } + if (coefficientOfKineticFriction != 0) { + setUpdatedForces([forceOfGravity, normalForce, frictionForce]); + } else { + setUpdatedForces([forceOfGravity, normalForce]); + } + } + }, [xVelocity]); + + const update = () => { + // RK4 update + let xPos = xPosition; + let yPos = yPosition; + let xVel = xVelocity; + let yVel = yVelocity; + for (let i = 0; i < 60; i++) { + let forces1 = getNewForces(xPos, yPos, xVel, yVel); + const xAcc1 = getNewAccelerationX(forces1); + const yAcc1 = getNewAccelerationY(forces1); + const xVel1 = getNewVelocity(xVel, xAcc1); + const yVel1 = getNewVelocity(yVel, yAcc1); + + let xVel2 = getNewVelocity(xVel, xAcc1 / 2); + let yVel2 = getNewVelocity(yVel, yAcc1 / 2); + let xPos2 = getNewPosition(xPos, xVel1 / 2); + let yPos2 = getNewPosition(yPos, yVel1 / 2); + const forces2 = getNewForces(xPos2, yPos2, xVel2, yVel2); + const xAcc2 = getNewAccelerationX(forces2); + const yAcc2 = getNewAccelerationY(forces2); + xVel2 = getNewVelocity(xVel2, xAcc2); + yVel2 = getNewVelocity(yVel2, yAcc2); + xPos2 = getNewPosition(xPos2, xVel2); + yPos2 = getNewPosition(yPos2, yVel2); + + let xVel3 = getNewVelocity(xVel, xAcc2 / 2); + let yVel3 = getNewVelocity(yVel, yAcc2 / 2); + let xPos3 = getNewPosition(xPos, xVel2 / 2); + let yPos3 = getNewPosition(yPos, yVel2 / 2); + const forces3 = getNewForces(xPos3, yPos3, xVel3, yVel3); + const xAcc3 = getNewAccelerationX(forces3); + const yAcc3 = getNewAccelerationY(forces3); + xVel3 = getNewVelocity(xVel3, xAcc3); + yVel3 = getNewVelocity(yVel3, yAcc3); + xPos3 = getNewPosition(xPos3, xVel3); + yPos3 = getNewPosition(yPos3, yVel3); + + let xVel4 = getNewVelocity(xVel, xAcc3); + let yVel4 = getNewVelocity(yVel, yAcc3); + let xPos4 = getNewPosition(xPos, xVel3); + let yPos4 = getNewPosition(yPos, yVel3); + const forces4 = getNewForces(xPos4, yPos4, xVel4, yVel4); + const xAcc4 = getNewAccelerationX(forces4); + const yAcc4 = getNewAccelerationY(forces4); + xVel4 = getNewVelocity(xVel4, xAcc4); + yVel4 = getNewVelocity(yVel4, yAcc4); + xPos4 = getNewPosition(xPos4, xVel4); + yPos4 = getNewPosition(yPos4, yVel4); + + xVel += + timestepSize * (xAcc1 / 6.0 + xAcc2 / 3.0 + xAcc3 / 3.0 + xAcc4 / 6.0); + yVel += + timestepSize * (yAcc1 / 6.0 + yAcc2 / 3.0 + yAcc3 / 3.0 + yAcc4 / 6.0); + xPos += + timestepSize * (xVel1 / 6.0 + xVel2 / 3.0 + xVel3 / 3.0 + xVel4 / 6.0); + yPos += + timestepSize * (yVel1 / 6.0 + yVel2 / 3.0 + yVel3 / 3.0 + yVel4 / 6.0); + } + + setXVelocity(xVel); + setYVelocity(yVel); + setXPosition(xPos); + setYPosition(yPos); + setUpdatedForces(getNewForces(xPos, yPos, xVel, yVel)); + }; + + let weightStyle = { + backgroundColor: color, + borderStyle: "solid", + borderColor: "black", + position: "absolute" as "absolute", + left: xPosition + "px", + top: yPosition + "px", + width: 2 * radius + "px", + height: 2 * radius + "px", + borderRadius: 50 + "%", + display: "flex", + justifyContent: "center", + alignItems: "center", + touchAction: "none", + }; + if (dragging) { + weightStyle.borderColor = "lightblue"; + } + + const [clickPositionX, setClickPositionX] = useState(0); + const [clickPositionY, setClickPositionY] = useState(0); + const labelBackgroundColor = `rgba(255,255,255,0.5)`; + + // Update x start position + useEffect(() => { + setUpdatedStartPosX(startPosX); + setXPosition(startPosX); + setXPosDisplay(startPosX); + }, [startPosX]); + + // Update y start position + useEffect(() => { + setUpdatedStartPosY(startPosY); + setYPosition(startPosY); + setYPosDisplay(startPosY); + }, [startPosY]); + + return ( + <div style={{ zIndex: -1000 }}> + <div + className="weightContainer" + onPointerDown={(e) => { + if (draggable) { + e.preventDefault(); + setPaused(true); + setDragging(true); + setClickPositionX(e.clientX); + setClickPositionY(e.clientY); + } else if (mode == "Review") { + setSketching(true); + } + }} + onPointerMove={(e) => { + e.preventDefault(); + if (dragging) { + let newY = yPosition + e.clientY - clickPositionY; + if (newY > yMax - 2 * radius) { + newY = yMax - 2 * radius; + } + + let newX = xPosition + e.clientX - clickPositionX; + if (newX > xMax - 2 * radius) { + newX = xMax - 2 * radius; + } else if (newX < 0) { + newX = 0; + } + + setXPosition(newX); + setYPosition(newY); + setUpdatedStartPosX(newX); + setUpdatedStartPosY(newY); + setDisplayYPosition( + Math.round((yMax - 2 * radius - newY + 5) * 100) / 100 + ); + setClickPositionX(e.clientX); + setClickPositionY(e.clientY); + setDisplayValues(); + } + }} + onPointerUp={(e) => { + if (dragging) { + e.preventDefault(); + if (!pendulum) { + resetEverything(); + } + setDragging(false); + let newY = yPosition + e.clientY - clickPositionY; + if (newY > yMax - 2 * radius) { + newY = yMax - 2 * radius; + } + + let newX = xPosition + e.clientX - clickPositionX; + if (newX > xMax - 2 * radius) { + newX = xMax - 2 * radius; + } else if (newX < 0) { + newX = 0; + } + if (pendulum) { + const x = xMax / 2 - newX - radius; + const y = newY + radius + 5; + let angle = (Math.atan(y / x) * 180) / Math.PI; + if (angle < 0) { + angle += 180; + } + let oppositeAngle = 90 - angle; + if (oppositeAngle < 0) { + oppositeAngle = 90 - (180 - angle); + } + + const pendulumLength = Math.sqrt(x * x + y * y); + setPendulumAngle(oppositeAngle); + setPendulumLength(Math.sqrt(x * x + y * y)); + const mag = 9.81 * Math.cos((oppositeAngle * Math.PI) / 180); + const forceOfTension: IForce = { + description: "Tension", + magnitude: mag, + directionInDegrees: angle, + }; + + setKineticFriction(false); + setXVelocity(startVelX ?? 0); + setYVelocity(startVelY ?? 0); + setDisplayValues(); + setUpdatedForces([forceOfGravity, forceOfTension]); + } + } + }} + > + <div className="weight" style={weightStyle}> + <p className="weightLabel">{mass} kg</p> + </div> + </div> + {pendulum && ( + <div + className="rod" + style={{ + pointerEvents: "none", + position: "absolute", + left: 0, + top: 0, + zIndex: -2, + }} + > + <svg width={xMax + "px"} height={window.innerHeight + "px"}> + <line + x1={xPosition + radius} + y1={yPosition + radius} + x2={xMax / 2} + y2={-5} + stroke={"#deb887"} + strokeWidth="10" + /> + </svg> + {!dragging && ( + <div> + <p + style={{ + position: "absolute", + zIndex: 5, + left: xPosition + "px", + top: yPosition - 70 + "px", + backgroundColor: labelBackgroundColor, + }} + > + {Math.round(pendulumLength)} m + </p> + <p + style={{ + position: "absolute", + zIndex: -1, + left: xMax / 2 + "px", + top: 30 + "px", + backgroundColor: labelBackgroundColor, + }} + > + {Math.round(pendulumAngle * 100) / 100}° + </p> + </div> + )} + </div> + )} + {!dragging && showAcceleration && ( + <div> + <div + style={{ + pointerEvents: "none", + position: "absolute", + zIndex: -1, + left: 0, + top: 0, + }} + > + <svg width={xMax + "px"} height={window.innerHeight + "px"}> + <defs> + <marker + id="accArrow" + markerWidth="10" + markerHeight="10" + refX="0" + refY="3" + orient="auto" + markerUnits="strokeWidth" + > + <path d="M0,0 L0,6 L9,3 z" fill="green" /> + </marker> + </defs> + <line + x1={xPosition + radius} + y1={yPosition + radius} + x2={xPosition + radius + getNewAccelerationX(updatedForces) * 5} + y2={yPosition + radius + getNewAccelerationY(updatedForces) * 5} + stroke={"green"} + strokeWidth="5" + markerEnd="url(#accArrow)" + /> + </svg> + <div + style={{ + pointerEvents: "none", + position: "absolute", + left: + xPosition + + radius + + getNewAccelerationX(updatedForces) * 5 + + 25 + + "px", + top: + yPosition + + radius + + getNewAccelerationY(updatedForces) * 5 + + 25 + + "px", + zIndex: -1, + lineHeight: 0.5, + }} + > + <p> + {Math.round( + 100 * + Math.sqrt( + Math.pow(getNewAccelerationX(updatedForces) * 3, 2) + + Math.pow(getNewAccelerationY(updatedForces) * 3, 2) + ) + ) / 100}{" "} + m/s<sup>2</sup> + </p> + </div> + </div> + </div> + )} + {!dragging && showVelocity && ( + <div> + <div + style={{ + pointerEvents: "none", + position: "absolute", + zIndex: -1, + left: 0, + top: 0, + }} + > + <svg width={xMax + "px"} height={window.innerHeight + "px"}> + <defs> + <marker + id="velArrow" + markerWidth="10" + markerHeight="10" + refX="0" + refY="3" + orient="auto" + markerUnits="strokeWidth" + > + <path d="M0,0 L0,6 L9,3 z" fill="blue" /> + </marker> + </defs> + <line + x1={xPosition + radius} + y1={yPosition + radius} + x2={xPosition + radius + xVelocity * 3} + y2={yPosition + radius + yVelocity * 3} + stroke={"blue"} + strokeWidth="5" + markerEnd="url(#velArrow)" + /> + </svg> + <div + style={{ + pointerEvents: "none", + position: "absolute", + left: xPosition + radius + xVelocity * 3 + 25 + "px", + top: yPosition + radius + yVelocity * 3 + "px", + zIndex: -1, + lineHeight: 0.5, + }} + > + <p> + {Math.round( + 100 * Math.sqrt(xVelocity * xVelocity + yVelocity * yVelocity) + ) / 100}{" "} + m/s + </p> + </div> + </div> + </div> + )} + {!dragging && + showForces && + updatedForces.map((force, index) => { + if (force.magnitude < epsilon) { + return; + } + let arrowStartY: number = yPosition + radius; + const arrowStartX: number = xPosition + radius; + let arrowEndY: number = + arrowStartY - + Math.abs(force.magnitude) * + 20 * + Math.sin((force.directionInDegrees * Math.PI) / 180); + const arrowEndX: number = + arrowStartX + + Math.abs(force.magnitude) * + 20 * + Math.cos((force.directionInDegrees * Math.PI) / 180); + + let color = "#0d0d0d"; + + let labelTop = arrowEndY; + let labelLeft = arrowEndX; + if (force.directionInDegrees > 90 && force.directionInDegrees < 270) { + labelLeft -= 120; + } else { + labelLeft += 30; + } + if (force.directionInDegrees >= 0 && force.directionInDegrees < 180) { + labelTop += 40; + } else { + labelTop -= 40; + } + labelTop = Math.min(labelTop, yMax + 50); + labelTop = Math.max(labelTop, yMin); + labelLeft = Math.min(labelLeft, xMax - 60); + labelLeft = Math.max(labelLeft, xMin); + + return ( + <div key={index}> + <div + style={{ + pointerEvents: "none", + position: "absolute", + zIndex: -1, + left: xMin, + top: yMin, + }} + > + <svg + width={xMax - xMin + "px"} + height={window.innerHeight + "px"} + > + <defs> + <marker + id="forceArrow" + markerWidth="10" + markerHeight="10" + refX="0" + refY="3" + orient="auto" + markerUnits="strokeWidth" + > + <path d="M0,0 L0,6 L9,3 z" fill={color} /> + </marker> + </defs> + <line + x1={arrowStartX} + y1={arrowStartY} + x2={arrowEndX} + y2={arrowEndY} + stroke={color} + strokeWidth="5" + markerEnd="url(#forceArrow)" + /> + </svg> + </div> + <div + style={{ + pointerEvents: "none", + position: "absolute", + left: labelLeft + "px", + top: labelTop + "px", + // zIndex: -1, + lineHeight: 0.5, + backgroundColor: labelBackgroundColor, + }} + > + {force.description && <p>{force.description}</p>} + {!force.description && <p>Force</p>} + {showForceMagnitudes && ( + <p>{Math.round(100 * force.magnitude) / 100} N</p> + )} + </div> + </div> + ); + })} + </div> + ); +}; |