In 1988, Steven Strogatz proposed using two people’s changing feelings for each other over time as an example for teaching ordinary differential equations. Read his eloquent one-page note.
Each person has two personality parameters determining whether they respond positively or negatively to their own feelings for someone else, and whether they respond positively or negatively to another person’s feelings for them. They also have some initial conditions here (first impressions of each other, you could say), though in a larger model you might want everyone to start at apathy and ignorance.
So if t
is time; R
is Romeo’s feelings for Juliet; J
is Juliet’s feelings for Romeo; and a
, b
, c
, and d
are personality parameters; you have:
dR/dt = aR + bJ
dJ/dt = cR + dJ
I think I first came across this in Prof. Pietraho and Prof. Zeeman’s classes at Bowdoin. I think it was on a final exam. J. C. Sprott has a good exposition of Strogatz’s model here.
Of course this is all very silly. But I like exercising a more dynamic vocabulary for relationship state than the static “being together” or “not being together”. I like thinking about orbits. There is something romantic about constant freefall under centripedal acceleration toward your mutual baryocenter. And I like puns about three-body systems.
But if there is one way in which math classes shaped my mind in a way unfit for relationships with people, it is the focus on the limiting behavior of systems. There is a lot of fatalism in math: either something will converge as t
approaches infinity, or it will diverge. But sometimes things converge very slowly! I guess this is an embarrassing error, but I think I internalized the asymptotic outlook to such an extent that hearing someone in my life on Earth say “timing is everything” is somehow jarring.
Math is necessary, universal, and eternal. Love is contingent, personal, and fleeting. In math, anything that isn’t always true is dishonest. In love, anything that would be true for anyone else is dishonest. In math, your proof should hold true for anyone, anywhere, at any time. In love, you are at your most honest when your words would be lies, or devoid of meaning, if they were said to anyone else, or at any other time.
Anyway, I know very little of either. There are probably bugs in here. Good chance something’s entirely upside-down.
—14 February 02016
<!DOCTYPE html>
<html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title></title>
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.5.1/katex.min.css">
<link rel="stylesheet" type="text/css" href="main.css"/>
<body>
<div class="container">
<div class="symbolics"></div>
<div class="instructions">
<div class="main">Click anywhere to start a new relationship (set initial conditions).</div>
<div class="params">Click in the personality quadrants to change Romeo and Juliet’s romantic personalities.</div>
</div>
</div>
</body>
<script src="d3.min.js" charset="utf-8"></script>
<script src="katex.min.js" charset="utf-8"></script>
<script src="main.js" charset="utf-8"></script>
</html>
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
font-family: sans-serif;
}
.container {
width: 100%;
height: 100%;
}
.instructions {
position: relative;
z-index: 3;
padding: 1em;
font-style: italic;
text-align: center;
pointer-events: none;
font-size: 10px;
}
svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
}
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
line {
stroke: black;
}
rect.click-capture {
pointer-events: all;
visibility: hidden;
cursor: pointer;
}
g.relationship {
pointer-events: none;
}
g.relationship path {
stroke: #ccc;
}
g.relationship text.prose {
font-size: 12px;
text-anchor: middle;
}
g.relationship text.prose {
font-size: 10px;
}
g.parameter-control rect.frame {
stroke: black;
stroke-width: 1;
fill: rgba(255,255,255,.5);
cursor: crosshair;
}
g.parameter-control path.axis {
stroke: #ddd;
}
g.parameter-control text {
text-anchor: middle;
font-size: 8px;
fill: #ccc;
pointer-events: none;
}
g.parameter-control text.title {
fill: #000;
font-weight: bold;
}
g.parameter-control text.prose {
fill: #000;
}
div.heart {
position: absolute;
z-index: 3;
transform: translate(-50%,-50%);
}
.symbolics {
position: absolute;
z-index: 3;
bottom: 20px;
left: 50%;
transform: translate(-50%,0);
background: rgba(255,255,255,.5);
pointer-events: none;
}
.symbolics .eq {
padding: .5em 0;
}
'use strict';
var hearts = ['💞','💖','💓','💜','💘'];
var randHeart = function() {
return hearts[Math.floor(Math.random() * hearts.length)];
}
// var maxParam = .03;
var maxParam = .01;
var randParam = function() {
return d3.scale.linear().range([-maxParam,maxParam])(Math.random());
}
var line = d3.svg.line();
var container = d3.select('.container');
var svg = container.append("svg"),
width = container.node().offsetWidth,
height = container.node().offsetHeight,
minDim = Math.min(width,height);
var feelingScale = d3.scale.quantize()
.domain([-minDim/4,-minDim/8,0,minDim/8,minDim/4])
.range(['hates','dislikes','is ok with','likes','loves']);
var feelingScaleEmoji = d3.scale.quantize()
.domain([-minDim/4,-minDim/8,0,minDim/8,minDim/4])
.range(['😩','😔','😐','😊','😍']);
var canvas = container.append("canvas")
.attr("width", width)
.attr("height", height);
var ctx = canvas.node().getContext('2d');
ctx.translate(width/2,height/2);
ctx.fillStyle = '#eee';
var g = svg.append("g")
.attr("transform", "translate(" + width/2 + "," + height/2 + ")");
var yAxis = g.append("line")
.attr('class', 'y axis')
.attr('x1', 0)
.attr('x2', 0)
.attr('y1', -height/2)
.attr('y2', height/2);
var xAxis = g.append("line")
.attr('class', 'x axis')
.attr('x1', -width/2)
.attr('x2', width/2)
.attr('y1', 0)
.attr('y2', 0);
var clickCapture = g.append('rect')
.attr('class', 'click-capture')
.attr('x', -width/2)
.attr('y', -height/2)
.attr('height', height)
.attr('width', width);
var relationship = {
'x': {
'dim': 'x',
'name': 'Romeo',
'value': Math.random() * width - width/2,
'coefficients': {
'x': randParam(),
'y': randParam()
}
},
'y': {
'dim': 'y',
'name': 'Juliet',
'value': Math.random() * height - height/2,
'coefficients': {
'x': randParam(),
'y': randParam()
}
}
};
var lesserRelationships = d3.range(20).map(newPoint);
var point = g.append("g")
.attr("class", "relationship")
.datum(relationship)
point.append("path").attr("class", "x");
point.append("path").attr("class", "y");
point.append("circle").attr("r", "4");
point.append("text").attr("class", "face x").attr("dy", ".3em").attr("dx", "-.5em");
point.append("text").attr("class", "face y").attr("dy", ".3em").attr("dx", "-.5em");
point.append("text").attr("class", "prose x").attr("dy", ".3em").style('text-anchor', 'middle');
point.append("text").attr("class", "prose y").attr("dy", ".3em").attr("dx", "-.5em")
g.on("click", function() {
// remove instruction to do this
d3.select('.instructions .main')
.remove();
var x = d3.mouse(this)[0]
var y = -d3.mouse(this)[1]
relationship.x.value = x;
relationship.y.value = y;
})
d3.select('.symbolics')
.datum(relationship)
.call(renderSymbolics);
svg.selectAll('g.parameter-control')
.data(Object.keys(relationship))
.enter()
.append('g')
.attr('class', 'parameter-control')
.attr("transform", function(d,i) {
if(i==0) {
return "translate(" + 25 + "," + (height-125) + ")"
} else {
return "translate(" + (width-125) + "," + (height-125) + ")"
}
})
.call(controlBox);
var lastHearted = 0;
var lastHeartedInterval = 100;
d3.timer(function(t) {
point
.each(function(d) {
// if it goes too far offscreen, reset
if(Math.abs(d.x.value) > width * 2 && Math.abs(d.y.value) > height * 2) {
d.x.value = Math.random() * width - width/2;
d.y.value = Math.random() * height - height/2;
}
// calculate one tick in the sim
var dims = Object.keys(relationship);
dims.forEach(function(dim) {
dims.forEach(function(dim2) {
d[dim].value += d[dim].coefficients[dim2] * d[dim2].value;
});
});
// leave trail
ctx.beginPath();
ctx.arc(d.x.value,-d.y.value,4,0,2*Math.PI);
ctx.fill();
// spew hearts if you're in love <3
if(feelingScale(d.x.value)=='loves' && feelingScale(d.y.value)=='loves') {
if(t-lastHearted > lastHeartedInterval) {
container.append('div')
.attr('class', 'heart')
.html(randHeart())
.style('left', (width/2 + d.x.value)+'px')
.style('top', (height/2 -d.y.value)+'px')
.style('opacity',1)
.transition()
.duration(750)
.ease('linear')
.style('left', (width/2 + d.x.value + (Math.random() * 100 - 50))+'px')
.style('top', (height/2 -d.y.value + (Math.random() * 100 - 50))+'px')
.style('opacity',0)
.remove();
lastHearted = t;
}
}
})
.attr("transform", function(d) { return "translate("+ d.x.value +","+ -d.y.value +")" });
// labelings lines and text and emoji
point.select('path.x').attr('d', function(d) { return line([[0,0],[-d.x.value,0]]); });
point.select('path.y').attr('d', function(d) { return line([[0,0],[0,d.y.value]]); });
point.select('text.face.x')
.attr("x", 0)
.attr("y", function(d) { return d.y.value; })
.text(function(d) { return feelingScaleEmoji(d.x.value)});
point.select('text.face.y')
.attr("x", function(d) { return -d.x.value; })
.attr("y", 0)
.text(function(d) { return feelingScaleEmoji(d.y.value)});
point.select('text.prose.x')
.attr("x", 0)
.attr("y", function(d) { return d.y.value; })
.attr("dy", function(d) { return 0.3 + (d.y.value > 0 ? 1.2 : -1.2) +'em'; })
.text(function(d) {
return 'Romeo ' + feelingScale(d.x.value) + ' Juliet';
});
point.select('text.prose.y')
.attr("x", function(d) { return -d.x.value; })
.attr("y", 0)
.style('text-anchor', function(d) { return d.x.value > 0 ? 'end' : 'start'; })
.attr('dx', function(d) { return (d.x.value < 0 ? 1.2 : -1.2) + 'em'; })
.text(function(d) {
return 'Juliet ' + feelingScale(d.y.value) + ' Romeo';
});
// draw the faint phase space field lines in the background
ctx.save();
ctx.strokeStyle = '#eee';
lesserRelationships.forEach(function(pt) {
// if it's gone offscreen, or just 1 in 100 other times (to keep things fresh)
if(Math.abs(pt.x) > width/2 || Math.abs(pt.y) > height/2 || Math.random() > .99) {
var newPt = newPoint();
pt.x = newPt.x;
pt.y = newPt.y;
return;
}
ctx.beginPath();
ctx.moveTo(pt.x,-pt.y);
pt.x += pt.x * relationship.x.coefficients.x + pt.y * relationship.x.coefficients.y;
pt.y += pt.x * relationship.y.coefficients.x + pt.y * relationship.y.coefficients.y;
ctx.lineTo(pt.x,-pt.y);
ctx.stroke();
});
ctx.restore();
})
function newPoint() {
return {
'x': d3.scale.linear().range([-width/2,width/2])(Math.random()),
'y': d3.scale.linear().range([-height/2,height/2])(Math.random())
}
}
// (semi)reusable component for the parameter spaces
function controlBox(selection) {
selection.each(function(data) {
var sel = d3.select(this);
data = relationship[data];
var w = 100;
var h = 100;
// scale clicks to param values
var mouseToParam = d3.scale.linear()
.domain([0,w/2])
.range([0,maxParam]);
// INITIAL BUILD
var g = sel.selectAll('g.inner')
.data([data])
.enter()
.append('g')
.attr("class", "inner")
.attr("transform", "translate(" + w/2 + "," + h/2 + ")");
g.append('path').attr("class", "x axis").attr('d', line([[-w/2,0],[w/2,0]]));
g.append('path').attr("class", "y axis").attr('d', line([[0,-h/2],[0,h/2]]));
g.append('rect').attr("class", "frame")
.attr('x', -w/2)
.attr('y', -h/2)
.attr('width', w)
.attr('height', h);
// label quadrants accd to J. C. Sprott
// http://sprott.physics.wisc.edu/pubs/paper277.pdf
g.append('text').attr('x', w/4).attr('y', -h/4).attr('dy', '.3em').text('EAGER')
g.append('text').attr('x', w/4).attr('y', h/4).attr('dy', '.3em').text('NARCISSIST')
g.append('text').attr('x', -w/4).attr('y', -h/4).attr('dy', '.3em').text('CAUTIOUS')
g.append('text').attr('x', -w/4).attr('y', h/4).attr('dy', '.3em').text('HERMIT')
g.append('text').attr('class', 'title').attr('y',-(h/2 + 10)).text(data.name.toUpperCase());
g.append('text').attr('class', 'prose one').attr('y', h/2 + 10);
g.append('text').attr('class', 'prose two').attr('y', h/2 + 20);
g.append('circle').attr('class', 'current').attr('r', 2);
var drag = d3.behavior.drag().on('drag', clickOrDrag);
g.on('click', clickOrDrag).call(drag);
update();
function clickOrDrag(d) {
// remove instruction to do this
d3.select('.instructions .params').remove();
// coefficient for own feeling
var a = mouseToParam(d3.mouse(this)[0]);
d.coefficients[d.dim] = a;
// coefficient for other's feeling
var b = mouseToParam(-d3.mouse(this)[1]);
d.coefficients[(d.dim==='x' ? 'y' : 'x')] = b;
update();
}
// UPDATE
function update() {
var a = data.coefficients[data.dim];
var b = data.coefficients[(data.dim==='x' ? 'y' : 'x')];
var hisHer = data.name == 'Romeo' ? 'his' : 'her';
var heShe = data.name == 'Romeo' ? 'he' : 'she';
var other = data.name == 'Romeo' ? 'Juliet' : 'Romeo';
// this text also comes from
// http://sprott.physics.wisc.edu/pubs/paper277.pdf
var prose1, prose2;
if(a > 0 && b > 0) {
prose1 = 'is encouraged by '+hisHer+' own feelings';
prose2 = 'as well as '+ other +'’s';
} else if(a > 0 && b < 0) {
prose1 = 'wants more of what '+heShe+' feels';
prose2 = 'but retreats from '+ other +'’s feelings';
} else if(a < 0 && b > 0) {
prose1 = 'retreats from '+hisHer+' own feelings';
prose2 = 'but is encouraged by '+other+'’s';
} else if(a < 0 && b < 0) {
prose1 = 'retreats from '+hisHer+' own feelings';
prose2 = 'as well as '+other+'’s';
}
sel.select('circle.current')
.attr('cx', mouseToParam.invert(a))
.attr('cy', mouseToParam.invert(-b))
sel.select('.prose.one').text(prose1);
sel.select('.prose.two').text(prose2);
// update symbolic equation
d3.select('.symbolics').call(renderSymbolics);
// clear phase space field trails
ctx.save();
ctx.fillStyle = '#fff';
ctx.globalAlpha = .8;
ctx.fillRect(-width/2,-height/2,width,height);
ctx.restore();
// reset seeds for phase space trails
lesserRelationships = d3.range(20).map(newPoint);
}
})
}
// SYMBOLIC REPRESENTATION
// per request by bret victor ;)
// https://twitter.com/worrydream/status/699068236214566915
function renderSymbolics(selection) {
selection.each(function(data) {
var sel = d3.select(this);
var dR = sel.selectAll("div.eq.dR").data([data.x]);
dR.enter().append("div").attr('class', 'eq dR');
var dJ = sel.selectAll("div.eq.dJ").data([data.y]);
dJ.enter().append("div").attr('class', 'eq dJ');
var a = (data.x.coefficients.x * 1000).toPrecision(3);
var b = (data.x.coefficients.y * 1000).toPrecision(3);
katex.render("\\dfrac{dR}{dt} = "+a+"R + "+b+"J", dR.node());
var c = (data.y.coefficients.x * 1000).toPrecision(3);
var d = (data.y.coefficients.y * 1000).toPrecision(3);
katex.render("\\dfrac{dJ}{dt} = "+c+"R + "+d+"J", dJ.node());
})
}
- Trace-determinant plane
- Lots more lovers
- Transition reparameterization (click and dot quickly transitions to new point, everything shifts)
- Click spawns new couple
- Randomize names
- Use matrices? lol
- Should also try a catastrophe theory model :)