Last active
August 18, 2021 05:38
-
-
Save kuanb/c5cfd0000cec44f05a037a5cce8397db to your computer and use it in GitHub Desktop.
Playing w/ traffic simulation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<meta http-equiv="X-UA-Compatible" content="ie=edge"> | |
<title>Traffic Simulation</title> | |
</head> | |
<body> | |
<h3>Controls</h3> | |
<p> | |
<p>Vehicle Generation State</p> | |
<input type="radio" id="removeCars" name="carAddRemoveRadio" value="-1"> | |
<label for="removeCars">Remove cars</label><br> | |
<input type="radio" id="doNothing" name="carAddRemoveRadio" value="0" checked> | |
<label for="doNothing">Do nothing</label><br> | |
<input type="radio" id="addCars" name="carAddRemoveRadio" value="1"> | |
<label for="addCars">Add cars</label> | |
<p> | |
</p> | |
Speed Multiplier | |
<input type="range" min="1" max="10" value="2" class="slider" id="vehicleSpeedsMultiplier"> | |
</p> | |
</p> | |
<input type="checkbox" id="loopOnOff" name="loopOnOff" onclick="toggleLooping(this)" checked> | |
<label for="loopOnOff"> Vehicles Loop Route</label> | |
</p> | |
</p> | |
<button id="triggerBreakDown" onclick="triggerBreakDown()">Trigger Break Down</button> | |
<button id="unblockBreakDowns" onclick="unblockBreakDowns()">Unblock Break Downs</button> | |
<button id="stopRunCycle" onclick="clearInterval(runCycle)">Stop Run Cycle</button> | |
</p> | |
<h3>Stats</h3> | |
<p>Total Vehicle Count: <span id="totalVehicleCount"></span></p> | |
<p>Avg Speed: <span id="avgSpeedStatRendered"></span></p> | |
</body> | |
<script src="https://d3js.org/d3.v6.min.js"></script> | |
<script type="text/javascript"> | |
// reference sentinel values | |
const speedRange = 1.5; | |
const speedLimit = 4; | |
const laneCount = 5; | |
let VEH_ID_INCREMENTER = 0; | |
const TRIGGER_BREAKDOWN_FLAG = {on: false, row: null}; | |
const IS_LOOPING_FLAG = {on: true} | |
const TOOBIG = 9999; | |
const DomWidth = window.innerWidth * 0.9; | |
const DomHeight = 100; | |
const DomSVG = d3.select("body").append("svg").attr("width", DomWidth).attr("height", DomHeight).attr("style", "border:1px solid black"); | |
function getUserSpeedMultiplier () { | |
return Number(document.getElementById("vehicleSpeedsMultiplier").value); | |
} | |
function triggerBreakDown () { | |
TRIGGER_BREAKDOWN_FLAG.on = true; | |
TRIGGER_BREAKDOWN_FLAG.row = Math.floor(Math.random() * laneCount); | |
} | |
function resetBreakDownFlag () { | |
TRIGGER_BREAKDOWN_FLAG.on = false; | |
TRIGGER_BREAKDOWN_FLAG.row = null; | |
TRIGGER_BREAKDOWN_FLAG.inResetState = false; | |
} | |
function unblockBreakDowns () { | |
for (let acali = 0; acali < laneCount; acali++) { | |
allCarsAllLanes[acali].forEach(ea => { | |
ea.isBrokenDown = false; | |
}) | |
} | |
} | |
function toggleLooping (toggleCheckbox) { | |
IS_LOOPING_FLAG.on = toggleCheckbox.checked; | |
} | |
function getAddOrRemoveLikelihood () { | |
let returnVal = 0; | |
document.getElementsByName("carAddRemoveRadio").forEach(function (ea) { | |
if (ea.checked) { | |
returnVal = Number(ea.value); | |
} | |
}) | |
return returnVal; | |
} | |
class Vehicle { | |
constructor(laneNumber, vehId) { | |
let requiredSpacingComfortFactor = Math.random(); | |
// create a unique id for tracking and increment global counter | |
this.id = vehId; | |
this.lane = laneNumber; | |
this.distance = 0; | |
this.currentSpeed = 0; | |
this.acceleration = 0.2 + Math.random(); | |
this.braking = (1 + Math.random()) * -1; | |
this.hardBraking = 2 * this.braking; | |
this.vehicleLength = 20 + (Math.random() * 10); | |
this.vehicleWidth = 10; | |
this.requiredFrontSpacing = 5 + (requiredSpacingComfortFactor * 55); | |
this.color = d3.interpolateCubehelixDefault(requiredSpacingComfortFactor); | |
this.isPolite = requiredSpacingComfortFactor > 0.5; | |
this.isNervous = this.isPolite && (Math.random() < 0.95); | |
this.isAggressive = !this.isPolite && (Math.random() < 0.95); | |
this.willPaceCarInFront = this.isPolite && !this.isNervous && (Math.random() < 0.95); | |
this.isBrokenDown = false; | |
this.isMerging = {state: false, phase: 0, destLane: null, mergeBehindCarId: null}; | |
// speed based on personality of driver, in part | |
if (this.isNervous) { | |
this.topSpeed = speedLimit + ((Math.random() * 0.25) * speedRange) - speedRange/2; | |
} else if (this.isAggressive) { | |
this.topSpeed = speedLimit + ((Math.random() * 0.5 + 0.5) * speedRange) - speedRange/2; | |
} else { | |
this.topSpeed = speedLimit + ((Math.random() * 0.5 + 0.25) * speedRange) - speedRange/2; | |
} | |
} | |
minOffsetFromDistance () { | |
return this.vehicleLength + this.requiredFrontSpacing; | |
} | |
maximumComfortableDistanceInFront () { | |
return this.distance + this.currentSpeed + this.minOffsetFromDistance(); | |
} | |
speedIfAccelerateFully () { | |
return Math.min((this.currentSpeed + this.acceleration), this.topSpeed); | |
} | |
speedIfDecelerating () { | |
return Math.max((this.currentSpeed + this.braking), 0); | |
} | |
speedIfHardBraking () { | |
return Math.max((this.currentSpeed + this.hardBraking), 0); | |
} | |
updateDistanceAfterCurrentSpeedUpdated () { | |
if (!this.isBrokenDown) { | |
this.distance = this.distance + (this.currentSpeed * getUserSpeedMultiplier()); | |
} | |
} | |
accelerateForwardMax () { | |
this.currentSpeed = this.speedIfAccelerateFully(); | |
} | |
decelerateForwards () { | |
this.currentSpeed = this.speedIfDecelerating(); | |
} | |
hardBrakingForwards () { | |
this.currentSpeed = this.speedIfHardBraking(); | |
} | |
setCustomSpeed (customSpeed) { | |
this.currentSpeed = customSpeed; | |
} | |
fullStop () { | |
this.currentSpeed = 0; | |
} | |
breakDown () { | |
this.isBrokenDown = true; | |
this.currentSpeed = 0; | |
} | |
resetMergingState () { | |
this.lane = this.isMerging.destLane; | |
this.isMerging = {state: false, phase: 0, destLane: null, mergeBehindCarId: null}; | |
this.domElement.style("fill", this.color).style("opacity", 0.5); | |
} | |
estimatePotentialOffsetDistance (customSpeedInput) { | |
return this.distance + this.minOffsetFromDistance() + (customSpeedInput * getUserSpeedMultiplier()); | |
} | |
getMergeSpaceNeeded () { | |
// determine how much of a squeeze is ok for merges | |
let howMuchSpaceNeededForMerge = {front: 0.5, back: 1.5}; | |
if (this.isAggressive) { | |
howMuchSpaceNeededForMerge = {front: 0.25, back: 0.75}; | |
} | |
howMuchSpaceNeededForMerge.distanceBack = this.distance - (howMuchSpaceNeededForMerge.back * this.minOffsetFromDistance()); | |
howMuchSpaceNeededForMerge.distanceFront = this.distance + (howMuchSpaceNeededForMerge.front * this.minOffsetFromDistance()); | |
return howMuchSpaceNeededForMerge; | |
} | |
considerMerging(carInFront, leftLaneNumber, leftLaneCars, rightLaneNumber, rightLaneCars) { | |
let oddsOfMerging = Math.random(); | |
if (this.isAggressive) { | |
oddsOfMerging += 0.2; | |
} else if (this.isNervous) { | |
oddsOfMerging -= 0.5; | |
} else if (this.isPolite){ | |
oddsOfMerging -= 0.1; | |
} | |
let shouldMerge = false; | |
if (!carInFront) { | |
// no need to merge if already in front | |
shouldMerge = false; | |
} else if (carInFront.distance < this.estimatePotentialOffsetDistance(this.speedIfAccelerateFully()) * 2) { | |
if (carInFront.currentSpeed < (this.currentSpeed * 0.9)) { | |
shouldMerge = true; | |
} | |
} else if (carInFront.distance < this.estimatePotentialOffsetDistance(this.speedIfAccelerateFully()) * 5) { | |
if (carInFront.currentSpeed < (this.currentSpeed * 0.8)) { | |
shouldMerge = true; | |
} | |
} | |
// do not merge off screen | |
if (this.distance < (DomWidth * 0.15)) { | |
shouldMerge = false; | |
} else if (this.distance < (DomWidth > 0.85)) { | |
shouldMerge = false; | |
} | |
// even if you can maybe you won't | |
shouldMerge = (shouldMerge && oddsOfMerging > 0.975); | |
const howMuchSpaceNeededForMerge = this.getMergeSpaceNeeded(); | |
// if you can need to pick which lane | |
let mergeToLane = null; | |
let nextCarInMergeLane = null; | |
if (shouldMerge && leftLaneCars) { | |
let leftSub = leftLaneCars | |
.filter(ea => ea.distance > howMuchSpaceNeededForMerge.distanceBack) | |
.filter(ea => ea.distance < howMuchSpaceNeededForMerge.distanceFront); | |
if (leftSub.length) { | |
mergeToLane = leftLaneNumber; | |
let upcomingLeftCars = leftLaneCars.filter(ea => ea.distance >= howMuchSpaceNeededForMerge.distanceFront); | |
if (upcomingLeftCars.length) { | |
nextCarInMergeLane = upcomingLeftCars[0].id | |
} | |
} | |
} | |
if (shouldMerge && rightLaneCars) { | |
let rightSub = rightLaneCars | |
.filter(ea => ea.distance > howMuchSpaceNeededForMerge.distanceBack) | |
.filter(ea => ea.distance < howMuchSpaceNeededForMerge.distanceFront); | |
if (rightSub.length) { | |
let didUseRight = false; | |
if ((mergeToLane != null) && (Math.random() > 0.7)) { | |
mergeToLane = rightLaneNumber; | |
didUseRight = true; | |
} else { | |
mergeToLane = rightLaneNumber; | |
didUseRight = true; | |
} | |
if (didUseRight) { | |
let upcomingRightCars = rightLaneCars.filter(ea => ea.distance >= howMuchSpaceNeededForMerge.distanceFront); | |
if (upcomingRightCars.length) { | |
nextCarInMergeLane = upcomingRightCars[0].id | |
} | |
} | |
} | |
} | |
if ((this.isMerging || shouldMerge) && (mergeToLane != null)) { | |
if (!this.isMerging.state) { | |
this.isMerging.state = true; | |
this.isMerging.mergeBehindCarId = nextCarInMergeLane; | |
this.isMerging.destLane = mergeToLane; | |
this.isMerging.phase = 1; | |
this.domElement.style("fill", "red").style("opacity", 0.85); | |
} | |
} | |
return this.isMerging.state; | |
} | |
updateVehiclePosition (carInFront, carInFrontInMergeLane) { | |
if (!carInFront) { | |
this.accelerateForwardMax(); | |
} else if (this.isBrokenDown) { | |
// pass no action if broken down | |
} else { | |
// handle when there are cars that have looped | |
let adjustedDistanceAhead = carInFront.distance; | |
if (carInFrontInMergeLane && carInFrontInMergeLane.distance < adjustedDistanceAhead) { | |
adjustedDistanceAhead = carInFrontInMergeLane.distance; | |
} | |
if (IS_LOOPING_FLAG.on && (adjustedDistanceAhead <= this.distance)) { | |
adjustedDistanceAhead += DomWidth; | |
} | |
if (this.estimatePotentialOffsetDistance(this.speedIfAccelerateFully()) < adjustedDistanceAhead) { | |
this.accelerateForwardMax(); | |
} else if (this.estimatePotentialOffsetDistance(this.currentSpeed) < adjustedDistanceAhead) { | |
if (!this.isPolite) { | |
this.accelerateForwardMax(); | |
} else if (this.isNervous) { | |
this.decelerateForwards(); | |
} else if (this.willPaceCarInFront) { | |
if (carInFrontInMergeLane && carInFrontInMergeLane.currentSpeed < carInFront.currentSpeed) { | |
this.setCustomSpeed(carInFrontInMergeLane.currentSpeed); | |
} else { | |
this.setCustomSpeed(carInFront.currentSpeed); | |
} | |
} | |
// do nothing since no need to update speed if being polite | |
} else if (this.estimatePotentialOffsetDistance(this.speedIfDecelerating()) < adjustedDistanceAhead) { | |
if (!this.isPolite) { | |
this.decelerateForwards(); | |
} else if (this.isNervous) { | |
this.hardBrakingForwards(); | |
} else { | |
// try and move forward at current speed | |
} | |
} else if (this.estimatePotentialOffsetDistance(this.speedIfHardBraking()) < adjustedDistanceAhead) { | |
this.hardBrakingForwards(); | |
} else { | |
this.fullStop(); | |
} | |
} | |
this.updateDistanceAfterCurrentSpeedUpdated(); | |
} | |
redrawCarLocation () { | |
this.domElement.attr("x", this.distance); | |
// only keep merging if moving forward (no sideways sliding) | |
if (this.isMerging.state && this.currentSpeed > 0) { | |
const shiftAmount = 15 * ((this.isMerging.destLane - this.lane) / 10) * this.isMerging.phase; | |
const adjY = shiftAmount + 15 + 15 * this.lane; | |
this.domElement.attr("y", adjY); | |
} | |
} | |
addCarToDom () { | |
this.domElement = DomSVG | |
.append("rect") | |
.style("fill", this.color) | |
.attr("x", this.distance) | |
.attr("y", (15 + 15 * this.lane)) | |
.attr("opacity", 0.5) | |
.attr("height", this.vehicleWidth) | |
.attr("width", this.vehicleLength); | |
} | |
deleteFromDom () { | |
this.domElement.remove(); | |
} | |
} | |
function getCarById (targetId) { | |
for (let acali = 0; acali < laneCount; acali++) { | |
let allCars = allCarsAllLanes[acali]; | |
allCars.forEach(currCar => { | |
if (currCar.id == targetId) { | |
return currCar; | |
} | |
}); | |
} | |
} | |
let allCarsAllLanes = []; | |
for (let i = 0; i < laneCount; i++) { | |
allCarsAllLanes.push([]) | |
} | |
const runCycle = setInterval(function() { | |
const allAverageSpeeds = []; | |
// merge phase - make merge changes | |
for (let acali = 0; acali < laneCount; acali++) { | |
let allCars = allCarsAllLanes[acali]; | |
// first thing is to prune out old merge operations | |
allCarsAllLanes[acali] = allCarsAllLanes[acali].filter(currCar => { | |
// handle orphaned already merged cars | |
if ((currCar.lane != acali) && !currCar.isMerging.state) { | |
return false; | |
} | |
return true; | |
}).map(currCar => { | |
// also increment phase of active mergers | |
if (currCar.isMerging.state && currCar.isMerging.destLane == acali) { | |
currCar.isMerging.phase = currCar.isMerging.phase + 1; | |
} | |
if (currCar.isMerging.state && currCar.isMerging.phase > 10) { | |
// reset merging state if destination lane has been reached | |
currCar.resetMergingState(); | |
} | |
return currCar; | |
}); | |
for (var i = allCars.length - 1; i >= 0; i--) { | |
let currCar = allCars[i]; | |
let nextCar = null; | |
if (i == allCars.length - 1) { | |
// this is the rightmost car | |
nextCar = null; | |
} else { | |
nextCar = allCars[i+1]; | |
} | |
let leftLaneNumber = null; | |
let leftLane = null; | |
let rightLaneNumber = null; | |
let rightLane = null; | |
if (acali > 0) { | |
leftLaneNumber = acali - 1; | |
leftLane = allCarsAllLanes[leftLaneNumber]; | |
} | |
if (acali < allCarsAllLanes.length - 1) { | |
rightLaneNumber = acali + 1; | |
rightLane = allCarsAllLanes[rightLaneNumber]; | |
} | |
// consider merging given current conditions for each car | |
if (!currCar.isMerging.state && currCar.considerMerging(nextCar, leftLaneNumber, leftLane, rightLaneNumber, rightLane)) { | |
let a = allCarsAllLanes[currCar.isMerging.destLane].filter(ea => ea.distance < currCar.distance); | |
let b = allCarsAllLanes[currCar.isMerging.destLane].filter(ea => ea.distance > currCar.distance); | |
allCarsAllLanes[currCar.isMerging.destLane] = a.concat([currCar]).concat(b); | |
} | |
} | |
} | |
for (let acali = 0; acali < laneCount; acali++) { | |
let allCars = allCarsAllLanes[acali]; | |
// there must always be at leat 1 car on the road | |
if (allCars.length == 0) { | |
const newVehicleToAdd = new Vehicle(acali, VEH_ID_INCREMENTER); | |
newVehicleToAdd.addCarToDom(); | |
VEH_ID_INCREMENTER += 1; | |
allCars = [newVehicleToAdd]; | |
} | |
let newCarsList = allCars.filter(ea => ea.distance < DomWidth); | |
let carsThatWentOffScreen = allCars.filter(ea => ea.distance >= DomWidth) | |
if (IS_LOOPING_FLAG.on) { | |
carsThatWentOffScreen = carsThatWentOffScreen.map(ea => { | |
ea.distance = Math.min(0, allCars[0].distance - (ea.minOffsetFromDistance())); | |
return ea; | |
}); | |
if (carsThatWentOffScreen.length) { | |
newCarsList = carsThatWentOffScreen.concat(newCarsList); | |
} | |
} else { | |
carsThatWentOffScreen.forEach(ea => { | |
ea.deleteFromDom(); | |
}); | |
} | |
for (var i = newCarsList.length - 1; i >= 0; i--) { | |
let currCar = newCarsList[i]; | |
let nextCarInMergeLane = null; | |
if (currCar.isMerging.state) { | |
nextCarInMergeLane = getCarById(currCar.isMerging.mergeBehindCarId); | |
} | |
// avoid drawing when we are a merge element and not already in lane | |
if (currCar.lane == acali) { | |
if (TRIGGER_BREAKDOWN_FLAG.on && TRIGGER_BREAKDOWN_FLAG.row == acali) { | |
currCar.breakDown(); | |
resetBreakDownFlag() | |
} | |
if (i == newCarsList.length - 1) { | |
// if the rightmost car, then reference the leftmost (if loop) | |
if (IS_LOOPING_FLAG.on) { | |
currCar.updateVehiclePosition(newCarsList[0], nextCarInMergeLane); | |
} else { | |
currCar.updateVehiclePosition(null, nextCarInMergeLane); | |
} | |
} else if (newCarsList.length <= 1) { | |
currCar.updateVehiclePosition(null, nextCarInMergeLane); | |
} else { | |
const carInFront = newCarsList[i + 1]; | |
currCar.updateVehiclePosition(carInFront, nextCarInMergeLane); | |
} | |
// update render location | |
currCar.redrawCarLocation(); | |
} | |
} | |
const newPotentialCar = new Vehicle(acali, VEH_ID_INCREMENTER); | |
const lastCarInLine = newCarsList[0]; | |
const likelihoodForAddingCarsOrRemoving = getAddOrRemoveLikelihood(); | |
if (likelihoodForAddingCarsOrRemoving > 0 && (Math.random() > 0.5)) { | |
if (lastCarInLine.distance > newPotentialCar.minOffsetFromDistance()) { | |
newPotentialCar.addCarToDom(); | |
VEH_ID_INCREMENTER += 1; | |
newCarsList = [newPotentialCar].concat(newCarsList); | |
} | |
} else if (likelihoodForAddingCarsOrRemoving < 0 && (Math.random() > 0.5)) { | |
const removeTheseCars = newCarsList.splice(-1, 1); | |
removeTheseCars.forEach(ea => { | |
ea.deleteFromDom(); | |
}) | |
} | |
allAverageSpeeds.extend(newCarsList.map(ea => ea.currentSpeed)); | |
allCarsAllLanes[acali] = newCarsList; | |
} | |
let averageCurrentSpeed = 0; | |
if (allAverageSpeeds.length) { | |
averageCurrentSpeed = allAverageSpeeds.reduce((a,b) => (a + b))/allAverageSpeeds.length; | |
} | |
document.getElementById("avgSpeedStatRendered").innerText = averageCurrentSpeed.toFixed(2); | |
document.getElementById("totalVehicleCount").innerText = allAverageSpeeds.length + " of " + VEH_ID_INCREMENTER + " total created"; | |
}, 50) | |
// utility function/s | |
Array.prototype.extend = function (other_array) { | |
/* You should include a test to check whether other_array really is an array */ | |
other_array.forEach(function(v) {this.push(v)}, this); | |
} | |
</script> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment