Skip to content

Instantly share code, notes, and snippets.

@ctlusto
Last active January 12, 2019 14:02
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ctlusto/839081f2cd9cf5217bb538f01bada2d5 to your computer and use it in GitHub Desktop.
Save ctlusto/839081f2cd9cf5217bb538f01bada2d5 to your computer and use it in GitHub Desktop.
An interactive exponential population model using Desmos
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="//cdn.jsdelivr.net/semantic-ui/2.2.6/semantic.min.css">
<link rel="stylesheet" href="scrubber.css">
<link rel="stylesheet" href="main.css">
<script src="//www.desmos.com/api/v0.8/calculator.js?apiKey=dcb31709b452b1cf9dc26972add0fda6"></script>
<!-- Load jQuery from Desmos instead of pulling in another copy -->
<script>window.jQuery = window.$ = Desmos.$</script>
<script src="//cdn.jsdelivr.net/semantic-ui/2.2.6/semantic.min.js"></script>
<script src="scrubber.js"></script>
<script src="index.js"></script>
<title>Exponential Population Growth</title>
</head>
<body>
<div class="wrapper">
<!-- Outer grid layout -->
<div class="ui two column stackable grid container">
<!-- Calculator column -->
<div class="column">
<div id="calculator" class="calculator"></div>
</div>
<!-- End calculator column -->
<!-- Parameters column -->
<div class="column">
<h1>Parameters</h1>
<i id="settings" class="icon setting"></i>
<table class="ui table">
<tr>
<td class="mq-label"><span id="n-label">N_0 = </span></td>
<td class="input-cell">
<div class="ui input">
<input type="text" id="n-input"></td>
</div>
<td id="n-slider"></td>
<td></td>
</tr>
<tr>
<td class="mq-label"><span id="r-label">r = </span></td>
<td class="input-cell">
<div class="ui input">
<input type="text" id="r-input">
</div>
</td>
<td id="r-slider"></td>
<td><button id="calculate-r" class="ui button">Calculate</button></td>
</tr>
<tr>
<td class="mq-label"><span id="t-label">t = </span></td>
<td class="input-cell">
<div class="ui input">
<input type="text" id="t-input">
</div>
</td>
<td id="t-slider"></td>
<td><button class="ui button" id="animation"><i></i>Play</button></td>
</tr>
</table>
<!-- Inner grid -->
<div class="ui two column grid">
<div class="column">
<table class="ui table">
<tr>
<td colspan="2" class="t-cell">Population <span id="nt-label">N(t) = </span><span id="nt-value"></span></td>
</tr>
<tr>
<td class="t-cell">slope = <span id="slope-value"></span></td>
<td><button class="ui button" id="show-slope">Show</button></td>
</tr>
</table>
</div>
<div class="column">
<canvas id="population-sketch" class="sketch" width=260 height=190></canvas>
</div>
</div>
<!-- End inner grid -->
</div>
<!-- End parameters column -->
</div>
<!-- End outer grid layout -->
<!-- Popup form for calculating r -->
<div id="r-calculation" class="ui popup">
<div class="ui labeled input popup-input">
<div class="ui label popup-label">Births</div>
<input type="text" id="births" class="r-input">
</div>
<br/>
<div class="ui labeled input popup-input">
<div class="ui label popup-label">Deaths</div>
<input type="text" id="deaths" class="r-input">
</div>
<br/>
<div class="ui labeled input popup-input">
<div class="ui label popup-label">Immigration</div>
<input type="text" id="immigration" class="r-input">
</div>
<br/>
<div class="ui labeled input popup-input">
<div class="ui label popup-label">Emigration</div>
<input type="text" id="emigration" class="r-input">
</div>
<button class="ui button" id="clear-r">Clear</button>
</div>
<!-- End of popup form -->
<!-- Popup form for setting min and max values -->
<div id="bounds" class="ui popup settings">
<table class="ui table">
<thead>
<tr class="center aligned">
<th>Parameter</th>
<th>Min</th>
<th>Max</th>
</tr>
</thead>
<tbody>
<tr>
<td>Pop. (<em>N<sub>0</sub></em>)</td>
<td>
<div class="ui input">
<input type="text" id='n-min'>
</div>
</td>
<td>
<div class="ui input">
<input type="text" id='n-max'>
</div>
</td>
</tr>
<tr>
<td>Rate (<em>r</em>)</td>
<td>
<div class="ui input">
<input type="text" id='r-min'>
</div>
</td>
<td>
<div class="ui input">
<input type="text" id='r-max'>
</div>
</td>
</tr>
<tr>
<td>Time (<em>t</em>)</td>
<td>
<div class="ui disabled input">
<input type="text" id='t-min'>
</div>
</td>
<td>
<div class="ui input">
<input type="text" id='t-max'>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- End of popup form -->
</div>
</body>
</html>
$(function() {
// Some useful globals
var showSlopeLine = false;
var timeIsAnimating = false;
var viewportBuffer = 1.1;
// Set up the canvas
var canvas = $('#population-sketch')[0];
var ctx = canvas.getContext('2d');
var w = canvas.width;
var h = canvas.height;
// Initialize the r-calculation popup
$('#calculate-r').popup({
popup: $('#r-calculation'),
on: 'click',
position: 'left center'
});
// Initialize the settings menu
$('#settings').popup({
popup: $('#bounds'),
on: 'click',
position: 'bottom right'
});
// Cache some DOM collections
var $nInput = $('#n-input'),
$rInput = $('#r-input'),
$tInput = $('#t-input'),
$nMin = $('#n-min'),
$nMax = $('#n-max'),
$rMin = $('#r-min'),
$rMax = $('#r-max'),
$tMin = $('#t-min'),
$tMax = $('#t-max'),
$showCapacity = $('#show-capacity'),
$showSlope = $('#show-slope'),
$slopeValue = $('#slope-value'),
$births = $('#births'),
$deaths = $('#deaths'),
$immigration = $('#immigration'),
$emigration = $('#emigration'),
$popValue = $('#nt-value')
$animation = $('#animation');
// Initialize the r calculator
$births.val(0);
$deaths.val(0);
$immigration.val(0);
$emigration.val(0);
// Initialize the bounds settings menu
$nMin.val(0);
$nMax.val(1500);
$rMin.val(0);
$rMax.val(2);
$tMin.val(0);
$tMax.val(10);
// set up the calculator
var elt = $('#calculator')[0];
var opts = {
expressions: false,
settingsMenu: false,
zoomButtons: false,
border: false,
lockViewport: true
};
var calc = Desmos.GraphingCalculator(elt, opts);
// MathQuill setup
var MQ = Desmos.MathQuill;
MQ.StaticMath($('#n-label')[0]);
MQ.StaticMath($('#r-label')[0]);
MQ.StaticMath($('#t-label')[0]);
MQ.StaticMath($('#nt-label')[0]);
// Create some sliders
var rScrubber = new ScrubberView();
var nScrubber = new ScrubberView();
var tScrubber = new ScrubberView();
// Helpers
function getStep(min, max) {
return (max - min) / 150; // slider is 150px wide
}
function clampValue(slider) {
if (slider.value() < slider.min()) slider.value(slider.min());
if (slider.value() > slider.max()) slider.value(slider.max());
}
// A member of the population. I mean, a dot.
function Member(x, y) {
this.x = Math.random() * w;
this.y = Math.random() * h;
this.r = w / 40;
this.color = '#2d70b3';
}
Member.prototype.render = function() {
ctx.beginPath();
ctx.arc(this.x, this.y, this.r, this.r, 0, 2*Math.PI);
ctx.fillStyle = this.color;
ctx.globalAlpha = 0.5;
ctx.fill();
};
// Keep track of the population and rendering
function Population(size) {
this.members = [];
for (var i=0; i<size; i++) {
this.members.push(new Member());
}
this.draw();
}
Population.prototype.draw = function() {
ctx.clearRect(0, 0, w, h);
this.members.forEach(function(elt) {
elt.render();
});
};
Population.prototype.setSize = function(size) {
// Don't try to create too many dots
size = Math.min(size, 5000);
var currentSize = this.members.length;
if (size === currentSize) return;
if (size < currentSize) {
this.members = this.members.slice(0, size);
}
if (size > currentSize) {
while (this.members.length < size) this.members.push(new Member());
}
window.requestAnimationFrame(this.draw.bind(this));
};
// The overall model
function Model(opts) {
this.initialPopulation = opts.initialPopulation;
this.rate = opts.rate;
this.minPopulation = opts.minPopulation;
this.maxPopulation = opts.maxPopulation;
this.minRate = opts.minRate;
this.maxRate = opts.maxRate;
this.maxTime = opts.maxTime;
this.init();
}
Model.prototype.init = function() {
this.population = new Population(this.initialPopulation);
this.computeSteps();
// Set up the initial calculator state
calc.setExpressions([
{ id: 'N_0', latex: 'N_0=' + this.initialPopulation },
{ id: 'r', latex: 'r=' + this.rate, hidden: true },
{ id: 'T', latex: 'T=0' },
{ id: 'P', latex: 'P=N(T)', hidden: true },
{ id: 'm', latex: 'm=N\'(T)' },
{ id: 'time-point', latex: '(T, N(T))', color: Desmos.Colors.RED },
{
id: 'curve',
latex: 'N\\left(t\\right)=\\N_0e^{rt}\\left\\{t\\ge 0\\right\\}',
color: Desmos.Colors.RED
},
{
id: 'slope-line',
latex: 'y-N(T) = m(x-T)',
color: Desmos.Colors.BLUE,
hidden: true
}
]);
this.updateBounds();
};
Model.prototype.computeSteps = function() {
this.populationStep = getStep(this.minPopulation, this.maxPopulation);
this.rateStep = getStep(this.minRate, this.maxRate);
this.timeStep = getStep(0, this.maxTime);
};
Model.prototype.setInitialPopulation = function(newPop) {
this.initialPopulation = newPop;
calc.setExpression({ id: 'N_0', latex: 'N_0=' + newPop });
};
Model.prototype.setRate = function(newRate) {
if (newRate < this.minRate) {
this.minRate = newRate;
rScrubber.min(newRate.toFixed(2));
clampValue(rScrubber);
this.computeSteps();
}
if (newRate > this.maxRate) {
this.maxRate = newRate;
rScrubber.max(newRate.toFixed(2));
clampValue(rScrubber);
this.computeSteps();
}
this.rate = newRate;
calc.setExpression({ id: 'r', latex: 'r=' + newRate });
};
Model.prototype.updateBounds = function() {
var xmax = this.maxTime * viewportBuffer;
var xmin = this.maxTime * (1 - viewportBuffer)
var ymax = this.maxPopulation * viewportBuffer;
var ymin = this.maxPopulation * (1 - viewportBuffer)
calc.setMathBounds({ left: xmin, right: xmax, bottom: ymin, top: ymax });
}
// Set the model's initialPopulation and rate.
// For instance, if you wanted to set from a saved state.
Model.prototype.setParameters = function(newParams) {
if (
newParams === undefined ||
newParams.initialPopulation === undefined ||
newParams.rate === undefined ||
newparams.minPopulation === undefined ||
newparams.maxPopulation === undefined ||
newparams.minRate === undefined ||
newparams.maxRate === undefined ||
newparams.maxTime === undefined
) {
throw new Error(
'\nYou must pass in an object with the following properties:\n' +
'initialPopulation\n' +
'rate\n' +
'minPopulation\n' +
'maxPopulation\n' +
'minRate\n' +
'maxRate\n' +
'maxTime'
);
}
this.setInitialPopulation(newParams.initialPopulation);
this.setRate(newParams.rate);
this.minPopulation = newParams.minPopulation;
this.maxPopulation = newParams.maxPopulation;
this.minRate = newParams.minRate;
this.maxRate = newParams.maxRate;
this.maxTime = newParams.maxTime;
this.computeSteps();
this.udpateBounds();
};
// Get the current model parameters. For instance, if you want to persist them.
Model.prototype.getParameters = function() {
return {
initialPopulation: this.initialPopulation,
rate: this.rate,
minPopulation: this.minPopulation,
maxPopulation: this.maxPopulation,
minRate: this.minRate,
maxRate: this.maxRate,
maxTime: this.maxTime
};
};
// Attach the model to the window object so that you can, e.g. get and set
// the model parameters from another script
window.model = new Model({
initialPopulation: 100,
minPopulation: 0,
maxPopulation: 1500,
rate: 0.6,
minRate: 0,
maxRate: 2,
maxTime: 10
});
// Listen to some important calculator values
var P = calc.HelperExpression({ latex: 'P' });
P.observe('numericValue', function() {
var currentPop = Math.round(P.numericValue);
model.population.setSize(currentPop);
$popValue.text(currentPop);
});
var T = calc.HelperExpression({ latex: 'T' });
function animationTimeout() {
clearTimeout(animationTimeout);
if (!timeIsAnimating) return;
var newTime = T.numericValue + model.timeStep;
if (newTime > model.maxTime) newTime = 0;
calc.setExpression({id: 'T', latex: 'T=' + newTime });
setTimeout(animationTimeout, 1000/60);
}
$animation.click(function() {
$animation.toggleClass('play').toggleClass('pause');
timeIsAnimating = $animation.hasClass('pause');
$animation.text(timeIsAnimating ? 'Pause' : 'Play');
animationTimeout();
});
var m = calc.HelperExpression({ latex: 'm' });
m.observe('numericValue', function() {
$slopeValue.text(m.numericValue.toFixed(2));
});
var r = calc.HelperExpression({ latex: 'r' });
var n = calc.HelperExpression({ latex: 'N_0' });
// Show the slope line
$showSlope.click(function() {
showSlopeLine = !showSlopeLine;
calc.setExpression({ id: 'slope-line', hidden: !showSlopeLine });
$showSlope.text(showSlopeLine ? 'Hide' : 'Show');
});
// Calculate and set r based on birth/death and immigration/emigration info
function setRateFromData() {
var b = $births.val(),
d = $deaths.val(),
i = $immigration.val(),
e = $emigration.val();
var rate = ( (b-d) + (i-e) );
model.setRate(rate);
}
[$births, $deaths, $immigration, $emigration].forEach(function(elt) {
elt.on('change', function() {
if (isNaN(elt.val())) elt.val(0);
setRateFromData();
});
});
// Clear the r-calculator inputs
$('#clear-r').click(function() {
$('.r-input').val(0);
});
// Set up sliders and keep them in sync with the inputs/calculator
nScrubber.min(0).max(1500).value(model.initialPopulation).step(10);
nScrubber.onValueChanged = function(val) {
$nInput.val(val);
model.setInitialPopulation(val);
};
nScrubber.elt.style.width = '150px';
$('#n-slider').append(nScrubber.elt);
n.observe('numericValue', function() {
nScrubber.value(n.numericValue);
});
rScrubber.min(0.1).max(2).value(model.rate).step(0.01);
rScrubber.onValueChanged = function(val) {
$rInput.val(val);
model.setRate(val);
};
rScrubber.elt.style.width = '150px';
$('#r-slider').append(rScrubber.elt);
r.observe('numericValue', function() {
rScrubber.value(r.numericValue);
});
tScrubber.min(0).max(10).step(0.01);
tScrubber.onValueChanged = function(val) {
$tInput.val(val);
calc.setExpression({ id: 'T', latex: 'T=' + val });
};
tScrubber.onScrubStart = function(val) {
// Kill animation if you grab the scrubber
if (timeIsAnimating) {
timeIsAnimating = false;
$animation.removeClass('pause').addClass('play');
$animation.text('Play');
}
};
tScrubber.elt.style.width = '150px';
$('#t-slider').append(tScrubber.elt);
T.observe('numericValue', function() {
tScrubber.value(T.numericValue);
});
function sanitizeInput(input) {
return isNaN(input) ? 0 : input;
}
// Initialize inputs
$nInput.val(model.initialPopulation);
$nInput.on('change', function() {
nScrubber.value(sanitizeInput($nInput.val()));
});
$rInput.val(model.rate);
$rInput.on('change', function() {
rScrubber.value(sanitizeInput($rInput.val()));
});
$tInput.val(0);
$tInput.on('change', function() {
tScrubber.value(sanitizeInput($tInput.val()));
});
// The settings inputs
$nMin.on('change', function() {
var newVal = sanitizeInput($nMin.val());
model.minPopulation = newVal;
nScrubber.min(newVal);
clampValue(nScrubber);
model.computeSteps();
model.updateBounds();
});
$nMax.on('change', function() {
var newVal = sanitizeInput($nMax.val());
model.maxPopulation = newVal;
nScrubber.max(newVal);
clampValue(nScrubber);
model.computeSteps();
model.updateBounds();
});
$rMin.on('change', function() {
var newVal = sanitizeInput($rMin.val());
model.minRate = newVal;
rScrubber.min(newVal);
clampValue(rScrubber);
model.computeSteps();
});
$rMax.on('change', function() {
var newVal = sanitizeInput($rMax.val());
model.maxRate = newVal;
rScrubber.max(newVal);
clampValue(rScrubber);
model.computeSteps();
});
$tMax.on('change', function() {
var newVal = sanitizeInput($tMax.val());
model.maxTime = newVal;
tScrubber.max(newVal);
clampValue(tScrubber);
model.computeSteps();
model.updateBounds();
});
});
.calculator {
border: 1px solid #ddd;
width: 100%;
height: 500px;
}
.ui.container {
background: #ddd;
}
.wrapper {
margin-top: 50px;
}
.ui.button {
width: 100%;
}
.ui.input {
width: 100px;
}
.scrubber {
margin-bottom: 10px;
}
.mq-label {
text-align: right !important;
}
.input-cell {
text-align: left !important;
}
.t-cell {
width: 50%;
}
.sketch {
background: #fff;
}
#r-calculation {
width: 300px;
}
.popup-label {
width: 100px;
}
.popup-input {
margin: 0 5px 5px 10px;
}
#animation {
cursor: pointer;
}
#settings {
position: absolute;
top: 20px;
right: 15px;
cursor: pointer;
font-size: 30px;
color: #696969;
}
#settings:hover {
cursor: pointer;
font-size: 30px;
color: #4f81bd;
}
.settings {
width: 500px;
}
.scrubber {
margin-top: 10px;
width: 200px;
height: 40px;
position: relative;
}
.scrubber-vert {
margin-left: 10px;
width: 40px;
height: 200px;
position: relative;
}
.scrubber .track {
position: absolute;
top: 50%;
left: 0px;
width: 100%;
height: 6px;
background: #DDD;
border-radius: 3px;
margin-top: -3px;
}
.scrubber-vert .track {
position: absolute;
top: 0px;
height: 100%;
left: 50%;
width: 6px;
background: #DDD;
border-radius: 3px;
margin-left: -3px;
}
.scrubber .thumb {
-moz-box-sizing: border-box;
box-sizing: border-box;
position: absolute;
top: 50%;
left: 0px;
width: 22px;
height: 22px;
margin-left: -11px;
margin-top: -11px;
cursor: pointer;
opacity: 0.7;
border: 8px solid #BECFE4;
border-radius: 100%;
background: #4F81BD;
transition: border-width 0.2s ease 0s;
}
.scrubber-vert .thumb {
-moz-box-sizing: border-box;
box-sizing: border-box;
position: absolute;
top: 100%;
left: 50%;
width: 22px;
height: 22px;
margin-top: -11px;
margin-left: -11px;
cursor: pointer;
opacity: 0.7;
border: 8px solid #BECFE4;
border-radius: 100%;
background: #4F81BD;
transition: border-width 0.2s ease 0s;
}
.scrubber .thumb:hover,
.scrubber-vert .thumb:hover,
.thumb.dragging {
border-width: 0px;
opacity: 1;
}
function ScrubberView() {
this.makeAccessors();
this.createDOM();
this.attachListeners();
this.onValueChanged = function () {};
this.onScrubStart = function () {};
this.onScrubEnd = function () {};
}
ScrubberView.prototype.makeAccessors = function () {
var value = 0;
var min = 0;
var max = 1;
var step = 0;
var orientation = 'horizontal';
this.value = function (_value) {
if (_value === undefined) return value;
if (value === _value) return this;
_value = Math.max(min, Math.min(max, _value));
if (step > 0) {
var nsteps = Math.round((_value - min)/step);
var invStep = 1/step;
if (invStep === Math.round(invStep)) {
_value = (min*invStep + nsteps)/invStep;
} else {
_value = (min/step + nsteps)*step;
}
value = Math.max(min, Math.min(max, _value));
} else {
value = _value;
}
this.redraw();
this.onValueChanged(value);
return this;
};
this.min = function (_min) {
if (_min === undefined) return min;
if (min === _min) return this;
min = _min;
this.redraw();
return this;
};
this.max = function (_max) {
if (_max === undefined) return max;
if (max === _max) return this;
max = _max;
this.redraw();
return this;
};
this.step = function (_step) {
if (_step === undefined) return step;
if (step === _step) return this;
step = _step;
this.redraw();
return this;
};
this.orientation = function(_orientation) {
if (_orientation === undefined) return orientation;
if (_orientation === orientation) return this;
orientation = _orientation;
this.redraw();
return this;
};
};
ScrubberView.prototype.createDOM = function () {
this.elt = document.createElement('div');
this.track = document.createElement('div');
this.thumb = document.createElement('div');
this.elt.className = this.orientation() === 'horizontal' ? 'scrubber' : 'scrubber-vert';
this.track.className = 'track';
this.thumb.className = 'thumb';
this.elt.appendChild(this.track);
this.elt.appendChild(this.thumb);
};
ScrubberView.prototype.redraw = function () {
var frac = (this.value() - this.min())/(this.max() - this.min());
if (this.orientation() === 'horizontal') {
this.elt.className = 'scrubber';
this.thumb.style.top = '50%';
this.thumb.style.left = frac*100 + '%';
}
else {
this.elt.className = 'scrubber-vert';
this.thumb.style.left = '50%';
this.thumb.style.top = 100 - (frac*100) + '%';
}
};
ScrubberView.prototype.attachListeners = function () {
var self = this;
var mousedown = false;
var cachedLeft;
var cachedWidth;
var cachedTop;
var cachedHeight;
var start = function (evt) {
evt.preventDefault();
self.onScrubStart(self.value());
mousedown = true;
var rect = self.elt.getBoundingClientRect();
// NOTE: page[X|Y]Offset and the width and height
// properties of getBoundingClientRect are not
// supported in IE8 and below.
//
// Scrubber doesn't attempt to support IE<9.
var xOffset = window.pageXOffset;
var yOffset = window.pageYOffset;
cachedLeft = rect.left + xOffset;
cachedWidth = rect.width;
cachedTop = rect.top + yOffset;
cachedHeight = rect.height;
self.thumb.className += ' dragging';
};
var stop = function () {
mousedown = false;
cachedLeft = undefined;
cachedWidth = undefined;
cachedTop = undefined;
cachedHeight = undefined;
self.thumb.className = 'thumb';
self.onScrubEnd(self.value());
};
var setValueFromPageX = function (pageX) {
var frac = Math.min(1, Math.max((pageX - cachedLeft)/cachedWidth, 0));
self.value((1-frac)*self.min() + frac*self.max());
};
var setValueFromPageY = function (pageY) {
var frac = Math.min(1, Math.max(1 - (pageY - cachedTop)/cachedHeight, 0));
self.value((1-frac)*self.min() + frac*self.max());
};
this.elt.addEventListener('mousedown', start);
this.elt.addEventListener('touchstart', start);
document.addEventListener('mousemove', function (evt) {
if (!mousedown) return;
evt.preventDefault();
if (self.orientation() === 'horizontal')
setValueFromPageX(evt.pageX);
else
setValueFromPageY(evt.pageY);
});
document.addEventListener('touchmove', function (evt) {
if (!mousedown) return;
evt.preventDefault();
if (self.orientation() === 'horizontal')
setValueFromPageX(evt.changedTouches[0].pageX);
else
setValueFromPageY(evt.changedTouches[0].pageY);
});
this.elt.addEventListener('mouseup', function (evt) {
if (!mousedown) return;
evt.preventDefault();
if (self.orientation() === 'horizontal')
setValueFromPageX(evt.pageX);
else
setValueFromPageY(evt.pageY);
});
this.elt.addEventListener('touchend', function (evt) {
if (!mousedown) return;
evt.preventDefault();
if (self.orientation() === 'horizontal')
setValueFromPageX(evt.changedTouches[0].pageX);
else
setValueFromPageY(evt.changedTouches[0].pageY);
});
document.addEventListener('mouseup', stop);
document.addEventListener('touchend', stop);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment