After the last post, I was quite bothered by a few insufficiencies of the script.
1. The interactive elements are not efficiently integrated into the script.
2. I do not include a Model class.
3. Add to this the fact that I did not include any data visualizations aside from the grid presented on the canvas.
As my intention is to leave notes that are not too complicated, both for the reader and for myself, I will only deal with 1) and 3) in this post. My intention is to provide a template for model building. As I develop across the summer, I hope to share these outputs.
So let's work on implementation. I will break down the demonstration into three main parts. First, we will create and integrate the interactive inputs and buttons using some Javascript functions that will allow us to pass an Object (i.e., dictionary for those who speak Python...), widgets, containing the widget names and relevant parameter values. Then, we will replace the variable names that are now contained in the widgets object. Finally, we will develop script for creating, updating, resizing, and clearing D3 plots. Let's begin.
1. Creating and Updating Widgets with Javascript
Much of the setup for this problem is the same as before, with the exception that I have removed all of the <input>
and <button>
headers. Instead of listing these in the html document and separately linking these to one another and the model, I will create a function that performs these services.
You may notice that I have now centered the canvas and I have also created a <div>
for displaying the period. This make it easier to center the text and the value presented.
Before using any functions, we will need to create the widgetsConfig Object. This is an Object containing a separate Object for each widget. Each of the Objects in the second layer contain relevant parameter values like the min and max values for a range slider. For example, the following will be used for the numTypes input:
numTypes: { type:"input", inputType:"number", label:"Number of types:", value:"2", min:"2", max:"10" }
Next, we create a few functions for generating these interactive widgets. The main function is the initWidgets() to which we will pass the widgetsConfig Object. This function will cycle through the entries in widgetsConfig, creating the widget that is indicated by the type and, when relevant, inputType values. Within the createWidget() function, we add a the label to the left of the input widgets and usses the showWidgetValue() function to link create the value reflecting the widget value to the right of each range input (slider).
Then, after the widget is created in the createWidget() function, we add the widget to the widgets object, add an entry for the value (this is not strictly necessary, but it makes the dictionary simpler to navigate in the step function), and add listeners to the input widgets so that the correct value is drawn when the widget is changed. Finally, for the gridSize entry, we add the implied cellSize and plot the grid on the canvas. Once all widgets have been created, the Object is saved as widgets. We will use this to access values when building the model.
Hopefully you can intuit the advantage to approaching model construction in this manner. Each additional widget only requires a new entry in the widgetsConfig object. The end of this first section indicates how we will use the widgets Object. Notice that we will define widgets.gridSize.cellSize as follows:
canvas.width / widgets.gridSize.value
This pattern will recur across the remainder of the tutorial.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Schelling Segregation Model</title>
<style>
body {
font-family: sans-serif;
padding: 20px;
}
#periodDiv {
/* center */
display: block;
text-align: center;
}
canvas {
border: 1px solid #555;
image-rendering: auto;
margin-top: 0px;
/* center canvas */
display: block;
margin-left: auto;
margin-right: auto;
}
#controls > * {
margin-right: 10px;
vertical-align: middle;
}
</style>
<script src="https://d3js.org/d3.v7.min.js"></script>
</head>
<body>
<!-- controls container -->
<div id="controls"></div>
<br>
<div id = "periodDiv"><label>Period: </label><span id="period">0</span></div>
<canvas id="gridCanvas"></canvas>
<script>
function showWidgetValue(config, name) {
// if the widget parameters show value
if (config.showValue) {
const controls = document.getElementById("controls");
const span = document.createElement("span");
const spanId = config.displayId || name + "Value";
span.id = spanId;
span.textContent = config.value;
controls.appendChild(span);
}
}
function createWidget(name, config) {
const controls = document.getElementById("controls");
if (config.label) {
const lbl = document.createElement("label");
lbl.textContent = config.label;
controls.appendChild(lbl);
}
el = document.createElement(config.type);
el.id = name;
if (config.type === "button") {
el.textContent = config.text || "";
} else {
el.type = config.inputType;
["min","max","step","value"].forEach(attr => {
if (config[attr] != null) el.setAttribute(attr, config[attr]);
});
}
controls.appendChild(el);
showWidgetValue(config, name);
}
function initWidgets(configs) {
const widgets = {};
const controls = document.getElementById("controls");
controls.innerHTML = "";
for (const [name, cfg] of Object.entries(configs)) {
createWidget(name, cfg);
widgets[name] = { input: document.getElementById(name) };
widgets[name].value = widgets[name].input.value;
if (cfg.type !== "button") {
widgets[name].input.addEventListener("input", e => {
widgets[name].value = e.target.value;
if (cfg.showValue) {
const spanId = cfg.displayId || name + "Value";
widgets[name][spanId].textContent = e.target.value;
}
// custom script for calculating grid tiles
if (name === "gridSize") {
widgets.gridSize.cellSize = canvas.width / e.target.value;
if (gridDrawn) drawGrid();
}
});
}
if (cfg.showValue) {
const spanId = cfg.displayId || name + "Value";
widgets[name][spanId] = document.getElementById(spanId);
}
}
return widgets;
}
const canvas = document.getElementById("gridCanvas");
const ctx = canvas.getContext("2d");
let grid = [], emptyCells = [], agentDict = {}, keys = [];
let canvasScale = 0.85, gridDrawn = false;
// ======== Widget configurations ========
const widgetConfig = {
numTypes: { type:"input", inputType:"number", label:"Number of types:", value:"2", min:"2", max:"10" },
tolerance: { type:"input", inputType:"range", label:"Tolerance:", value:"0.7", min:"0", max:"1", step:"0.01", showValue:true },
gridSize: { type:"input", inputType:"range", label:"Neighbors per Side:",value:"100", min:"5", max:"100", step:"1", showValue:true },
percentVacant:{ type:"input", inputType:"range", label:"Percent Vacant:", value:"0.2", min:"0", max:"1", step:"0.01", showValue:true },
stepInterval: { type:"input", inputType:"range", label:"Step Interval:", value:"1", min:"0", max:"100", step:"1", showValue:true },
initBtn: { type:"button", text:"Initialize" },
stepBtn: { type:"button", text:"Step" },
runBtn: { type:"button", text:"Run" }
};
// initialize widgets
const widgets = initWidgets(widgetConfig);
// canvas resizing
function updateCanvasSize() {
if (window.innerWidth < window.innerHeight) {
canvas.width = window.innerWidth * canvasScale;
canvas.height = window.innerWidth * canvasScale;
} else {
canvas.width = window.innerHeight * canvasScale;
canvas.height = window.innerHeight * canvasScale;
}
widgets.gridSize.cellSize = canvas.width / widgets.gridSize.value;
if (gridDrawn) drawGrid();
}
updateCanvasSize();
window.addEventListener("resize", updateCanvasSize);
</script>
</body>
</html>
2. Replacing Variable Names
I will not belabor this next point. We will need to call from the widgets Object each of the variables that it now contains. You saw in the last several sentences of the previous section how this process will occur. I encourage you to do this by hand if you are able as this will help you develop your intuition. However, some parts of the model have changed slightly in this iteration of the tutorial. For example, I have added an Object named data that we will use to record the data as it is generated in the model. With this in mind, you may benefit from simply typing out the entire script to improve your familiarity with the model. Finally, notice that I have commented out script involving the createChartDiv() and updateChart() functions to help you recognize where these will be placed in the third section of this tutorial.
//. . .
// start from resize listener where we ended the script in the last section
window.addEventListener("resize", updateCanvasSize);
// period display
const period = document.getElementById("period");
widgets.initBtn.input.addEventListener("click", () => {
init();
keys = shuffleList(Object.keys(agentDict));
});
widgets.stepBtn.input.addEventListener("click", step);
const startStopBtn = widgets.runBtn.input;
let running = false;
startStopBtn.addEventListener("click", run);
// data for charts
let data = { percentSatisfied: [], numSatisfied: [] };
const dataKeys = Object.keys(data);
// ======== Core model functions ========
function init() {
agentDict = {}; keys = [];
grid = []; emptyCells = [];
data = { percentSatisfied: [], numSatisfied: [] };
period.textContent = 0;
let id = 0;
for (let x = 0; x < widgets.gridSize.value; x++) {
grid[x] = [];
for (let y = 0; y < widgets.gridSize.value; y++) {
if (Math.random() < widgets.percentVacant.value) {
grid[x][y] = -1;
emptyCells.push({ x, y });
} else {
const agentType = Math.floor(Math.random() * widgets.numTypes.value);
agentDict[id] = new Agent(x, y, agentType, id);
grid[x][y] = agentType;
id++;
}
}
}
keys = shuffleList(Object.keys(agentDict));
checkAllSatisfied();
drawGrid();
gridDrawn = true;
// for (const name of dataKeys) {
// createChartDiv(name);
// }
}
function drawGrid() {
ctx.clearRect(0,0,canvas.width,canvas.height);
for (let x = 0; x < widgets.gridSize.value; x++) {
for (let y = 0; y < widgets.gridSize.value; y++) {
const t = grid[x][y];
ctx.fillStyle = t < 0
? "#fff"
: `hsl(${(t/widgets.numTypes.value)*360},70%,50%)`;
let cellSize = widgets.gridSize.cellSize;
ctx.fillRect(x*cellSize,y*cellSize,cellSize,cellSize);
}
}
}
function updateData(numSat){
data.percentSatisfied.push(numSat/keys.length);
data.numSatisfied.push(numSat);
}
function checkAllSatisfied() {
let allSat = true, numSat = 0;
for (const k of keys) {
const a = agentDict[k];
a.getNeighbors(); a.checkSatisfaction();
if (a.satisfied) numSat++;
else allSat = false;
}
updateData(numSat)
return allSat;
}
function shuffleList(arr) {
for (let i = arr.length-1; i>0; i--) {
const j = Math.floor(Math.random()*(i+1));
[arr[i],arr[j]] = [arr[j],arr[i]];
}
return arr;
}
function outOfBounds(x,y) {
return x<0||x>=widgets.gridSize.value||y<0||y>=widgets.gridSize.value;
}
class Agent {
constructor(x,y,agentType,id) {
this.x=x; this.y=y;
this.agentType=agentType;
this.id=id; this.satisfied=false;
this.neighbors=[];
}
getNeighbors() {
this.neighbors = [];
for (let dx=-1; dx<=1; dx++) {
for (let dy=-1; dy<=1; dy++) {
if (dx===0&&dy===0) continue;
const nx=this.x+dx, ny=this.y+dy;
if (outOfBounds(nx,ny)) continue;
if (grid[nx][ny]===-1) continue;
this.neighbors.push(grid[nx][ny]);
}
}
}
checkSatisfaction() {
const same = this.neighbors.reduce((acc,n)=> acc + (n===this.agentType), 0);
this.percentSame = same/this.neighbors.length;
this.satisfied = this.percentSame >= +widgets.tolerance.value;
this.intPercentSame = Math.round(this.percentSame*100);
}
}
function step() {
keys = shuffleList(keys);
for (const k of keys) {
const a = agentDict[k];
if (a.satisfied) continue;
if (!emptyCells.length) break;
const i = Math.floor(Math.random()*emptyCells.length);
const cell = emptyCells[i];
if (grid[cell.x][cell.y]===-1) {
grid[a.x][a.y] = -1;
emptyCells.splice(i,1);
emptyCells.push({x:a.x,y:a.y});
a.x = cell.x; a.y = cell.y;
grid[a.x][a.y] = a.agentType;
}
}
const allSat = checkAllSatisfied();
drawGrid();
// increment the period
period.textContent = +period.textContent + 1;
// for (const name of dataKeys) updateChart(name);
return allSat;
}
function run() {
if (!gridDrawn) return;
if (running) {
running = false; startStopBtn.textContent = "Run";
} else {
running = true; startStopBtn.textContent = "Stop";
const iv = setInterval(()=>{
if (!running) { clearInterval(iv); return; }
if (step()) {
running = false;
startStopBtn.textContent = "Run";
}
},
widgets.stepInterval.value);
}
}
function findAgentAtCell(gx,gy) {
for (const k in agentDict) {
const a = agentDict[k];
if (a.x===gx && a.y===gy) {
let info = `agentType: ${a.agentType}\nSatisfaction: ${a.satisfied ? '😊 Happy':'😞 Sad'} \n ${a.intPercentSame}% similar neighbors`;
return info;
}
}
return "Empty cell";
}
// tooltip
const tooltip = document.createElement("div");
Object.assign(tooltip.style, {
position:"absolute", padding:"5px",
backgroundColor:"rgba(0,0,0,0.7)",
color:"#fff", borderRadius:"3px",
pointerEvents:"none", fontSize:"14px",
visibility:"hidden"
});
document.body.appendChild(tooltip);
canvas.addEventListener("mousemove", e => {
if (!gridDrawn) { tooltip.style.visibility="hidden"; return; }
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const gx = Math.floor(mx / widgets.gridSize.cellSize);
const gy = Math.floor(my / widgets.gridSize.cellSize);
tooltip.innerText = (!outOfBounds(gx,gy) && grid[gx][gy]!==-1)
? findAgentAtCell(gx,gy)
: "Empty cell";
tooltip.style.left = e.pageX + 10 + "px";
tooltip.style.top = e.pageY + 10 + "px";
tooltip.style.visibility = "visible";
});
canvas.addEventListener("mouseleave", () => {
tooltip.style.visibility = "hidden";
});
3. Adding Live Visualization
In this final section, we will add live visualization, which was not available in the previous tutorial. We will use the createChartDiv() function to add a <div>
for each visualization. This function will check if the chartDiv has already been created. If it has not, there is no need to continue in the function. If it has been created, then chartDivis deleted. This is an easy way of ensuring that we do not include the old data in the graph for the newly initialized simulation. (I am presuming that the <svg>
from the deleted <div>
does not continue to sit in memory.) The id of the chartDiv will be the chartName.
Next, the chartDiv is created. We generate the CSS on the fly, though we could have elected to manage the CSS for this div at the top of the HTML file. Importantly, this CSS make each graph 25% of the vertical height of the document. Of course, you can choose your own settings or could even create a slider to govern the dimensions of the graph. After attaching the newly created <div>
to the document, we will create the chart itself using the createChart() function. The function is intentionally minimalistic, leaving most work for the updateChart() function in order to prevent unnecessary repetition in the process of creating the graph. We will use the dimensions of the chartDiv to define the dimensions of the chart <svg>
. I have gone through the basics of creating a D3 plot in an earlier post, so I will only mention the key points here.
First, notice that we draw from the chartMargin Object in order to place the chart using in the line:
.attr("transform", `translate(${chartMargin.left},${chartMargin.top})`)
We also use these values to define the width and height variables in the updateChart() function. Our use of these values will ensure that the axes and the data are consistently shifted, thereby leaving room for the axis and tick labels. We also want to ensure that an appropriate number of ticks are used in order to prevent 1) repeated tick values and 2) overlapping tick labels.
const xTicks = Math.min(period.textContent, Math.floor(width/50));
const yTicks = Math.max(2, Math.min(10, Math.floor(height/30)));
We format axis tick labels with the following script. In this specific case, the y-axis values are rounded to 3 places. This is sufficient for the data we will use in this model, though I anticipate making the data more responsive to a variety of cases when working on other models:
We ensure consistent, centered placement of axis labels with the following commands:
// update labels<br>
svg.select(".x-label")
.attr("x", chartMargin.left + width/2)
.attr("y", H - 5);
svg.select(".y-label")
.attr("transform", `translate(15,${chartMargin.top + height/2}) rotate(-90)`);
Finally, automatically update the graphs using a resize listener. To do this, we call the forEach method from the dataKeys list, using each of these strings to access the related <div>
. We add a listner that calls resizeCharts(), which updates the chart dimensions using the updateChart() function. Once these are ready, make sure to uncomment the createChartDiv() and updateChart() calls in the init and step functions so that the visualizations are constructed and updated.
Of course, there is no replacement for actively working with the script and modifying and developing new features as you feel inclined.
// . . .
const chartMargin = { top: 10, right: 10, bottom: 45, left: 70 };
function clearChart(divname) {
const chartDiv = document.getElementById(divname);
if (!chartDiv) return;
chartDiv.remove();
}
function createChartDiv(chartname) {
clearChart(chartname);
const chartDiv = document.createElement("div");
chartDiv.id = chartname;
chartDiv.style.cssText = `
width:100%; height:25vh; margin-top:20px;
`;
document.body.appendChild(chartDiv);
createChart(chartname);
}
function createChart(divname) {
const container = document.getElementById(divname);
// svg + group skeleton
const W = container.clientWidth,
H = container.clientHeight;
const svg = d3.select(container)
.append("svg")
.attr("width", W)
.attr("height", H);
const g = svg.append("g")
.attr("transform", `translate(${chartMargin.left},${chartMargin.top})`)
.attr("class", "inner");
// placeholders: axis groups, labels, and line path
g.append("g").attr("class","x-axis");
g.append("g").attr("class","y-axis");
svg.append("text")
.attr("class","x-label")
.attr("text-anchor","middle")
.text("Period");
svg.append("text")
.attr("class","y-label")
.attr("text-anchor","middle")
.text(divname);
g.append("path")
.attr("class","line")
.attr("fill","none")
.attr("stroke","steelblue")
.attr("stroke-width",1.5);
// actual drawing in updateChart()
updateChart(divname);
}
function updateChart(divname) {
const container = document.getElementById(divname);
const svg = d3.select(container).select("svg");
const g = svg.select("g.inner");
const W = container.clientWidth,
H = container.clientHeight,
width = W - chartMargin.left - chartMargin.right,
height = H - chartMargin.top - chartMargin.bottom,
series = data[divname];
// resize svg
svg.attr("width", W).attr("height", H);
// rebuild scales
const xScale = d3.scaleLinear()
.domain([0, series.length - 1])
.range([0, width]);
const yScale = d3.scaleLinear()
.domain([0, d3.max(series)])
.range([height, 0]);
// set number of ticks to minimum of either period or width/50. width / 50 is max num ticks
const xTicks = Math.min(period.textContent, Math.floor(width/50));
const yTicks = Math.max(2, Math.min(10, Math.floor(height/30)));
// update axes
g.select(".x-axis")
.attr("transform",`translate(0,${height})`)
.call(d3.axisBottom(xScale).ticks(xTicks).tickFormat(d3.format("d")));
g.select(".y-axis")
.call(d3.axisLeft(yScale).ticks(yTicks).tickFormat(d3.format(".3f")));
// update labels
svg.select(".x-label")
.attr("x", chartMargin.left + width/2)
.attr("y", H - 5);
svg.select(".y-label")
.attr("transform",
`translate(15,${chartMargin.top + height/2}) rotate(-90)`);
// update line
g.select(".line")
.datum(series)
.attr("d", d3.line()
.x((d,i)=> xScale(i))
.y(d => yScale(d))
);
}
function resizeCharts() {
dataKeys.forEach(name => {
const c = document.getElementById(name);
if (!c) return;
c.style.width = window.innerWidth * 0.9 + "px";
c.style.height = window.innerHeight * 0.25 + "px";
updateChart(name);
});
}
window.addEventListener("resize", resizeCharts);