fig1 ={
const cities = await FileAttachment("assets/data/city_maps.json").json();
const container = document.createElement("div");
container.style.cssText = "position:relative;display:flex;flex-wrap:wrap;gap:24px;justify-content:center;align-items:flex-start;font-family:system-ui,sans-serif;margin-left:auto;margin-right:auto;max-width:45vw;";
if (window.innerWidth < 768) {
container.style.maxWidth = "100vw";
}
const grid = document.createElement("div");
grid.style.cssText = "display:grid;grid-template-columns:repeat(auto-fill, minmax(76px, 1fr));gap:6px;max-width:100%;";
let scale = {
"cve": "scale",
"name": "",
"detail": "assets/images/metros/scale.png",
"thumb": "assets/images/metros/scale.png",
"km": {
"r3": 0,
"r5": 0,
"r9_3": 0
}
};
const cardByCve = new Map();
cities.push(scale);
for (const c of cities) {
const btn = document.createElement("button");
btn.type = "button";
btn.title = c.name;
btn.style.cssText = "display:flex;flex-direction:column;gap:2px;padding:3px;border:2px solid transparent;border-radius:8px;background:#fff;cursor:default;font:inherit;select:none;user-select:none;pointer-events:auto;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;";
const im = document.createElement("img");
im.src = c.thumb; im.alt = c.name; im.loading = "lazy";
im.style.cssText = `
width: 100%;
height: ${c.cve === "scale" ? "110px" : "auto"};
aspect-ratio: ${c.cve === "scale" ? "auto" : "1"};
object-fit: ${c.cve === "scale" ? "contain" : "cover"};
margin-top: ${c.cve === "scale" ? "-0.6vh" : "0"};
border-radius: 5px;
background: #fff;
user-select: none;
pointer-events: none;
`;
const nm = document.createElement("span");
nm.textContent = c.name;
nm.style.cssText = "font-size:8.5px;line-height:1.1;color:#000;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;user-select:none;pointer-events:none;";
btn.append(im, nm);
cardByCve.set(c.cve, btn);
grid.append(btn);
}
container.append(grid);
return container;
}Scaling and Population Loss in Mexican Urban Centres
Cities are supposed to be busiest at their heart. In Mexico, the opposite happened. Over three decades of booming growth, people drained out of city centres and piled up on the fringes — in every single one of the country’s 69 large metropolitan areas.
The paradox: booming cities, emptying centres
Between 1990 and 2020, all 69 metropolitan areas lost population in their central zone, even as every one of them grew overall. Nationwide, central areas shed 2.5 million residents.
Review your city and compare against other city
figure2grid = {
const cities = await FileAttachment("assets/data/city_maps.json").json();
const density = await FileAttachment("assets/data/density_change.json").json();
const container2 = document.createElement("div");
const global_container = document.createElement("div");
global_container.style.cssText = "position:relative;display:flex;flex-direction:column;gap:12px;justify-content:center;align-items:center;font-family:system-ui,sans-serif;margin-left:auto;margin-right:auto;max-width:45vw;";
container2.style.cssText = "position: relative;width: 50%;max-width: 45vw;display: flex;align-items: center;justify-content: center;flex-direction: column;margin-top: 0px;";
if (window.innerWidth < 768) {
container2.style.maxWidth = "100vw";
}
const vmax = await FileAttachment("assets/data/map_vmax.json").json();
const zoneKeys = [
"central",
"intermediate",
"distant",
"periurban"
];
const zoneMeta = {
central: {
label: "Central",
range: "r < 3"
},
intermediate: {
label: "Intermediate",
range: "3 ≤ r < 5"
},
distant: {
label: "Distant",
range: "5 ≤ r < 9.3"
},
periurban: {
label: "Peri-urban",
range: "r ≥ 9.3"
}
};
const R = {
r3: 0.150,
r5: 0.250,
r93: 0.465
};
const LOSS = "#c65a43";
const GAIN = "#4e8ab2";
const HILITE = "rgba(20,61,87,0.18)";
const fmt = number =>
Number(number).toLocaleString("en-US");
const normalizeText = value =>
String(value ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim();
const normalizeCityCode = value =>
String(value ?? "")
.replace(/\.png$/i, "")
.trim();
const byCve = new Map(
cities.map(city => [city.cve, city])
);
let selectedLeft = "09.1.01";
let selectedRight = normalizeCityCode("01.1.01.png");
/*
* Prevent an invalid initial configuration in case either code
* is missing or both resolve to the same city.
*/
if (!byCve.has(selectedLeft)) {
selectedLeft = cities[0]?.cve;
}
if (
!byCve.has(selectedRight) ||
selectedRight === selectedLeft
) {
selectedRight =
cities.find(city => city.cve !== selectedLeft)?.cve;
}
// ---------------------------------------------------------------------------
// Main layout
// ---------------------------------------------------------------------------
const container = document.createElement("div");
container.style.cssText = `
position:relative;
display:grid;
grid-template-columns:minmax(33vw, 100vw) auto minmax(33vw, 100vw);
align-items:center;
justify-content:center;
width:100%;
box-sizing:border-box;
padding:8px 0;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
`;
const versus = document.createElement("div");
versus.textContent = "VS";
versus.style.cssText = `
align-self:start;
padding-top:20px;
color:#111;
font-size:clamp(24px, 2.4vw, 38px);
font-weight:500;
line-height:1;
text-align:center;
`;
// ---------------------------------------------------------------------------
// Shared tooltip
// ---------------------------------------------------------------------------
const tooltip = document.createElement("div");
tooltip.style.cssText = `
position:absolute;
z-index:1000;
max-width:250px;
box-sizing:border-box;
padding:7px 10px;
border:1px solid #ccc;
border-radius:6px;
background:#fff;
box-shadow:0 4px 14px rgba(0,0,0,0.14);
opacity:0;
pointer-events:none;
color:#333;
font-size:0.75rem;
line-height:1.4;
transition:opacity 100ms ease;
`;
// ---------------------------------------------------------------------------
// City panel factory
// ---------------------------------------------------------------------------
function createCityPanel(side) {
const panel = document.createElement("section");
panel.style.cssText = `
width:100%;
min-width:0;
`;
// -------------------------------------------------------------------------
// Header: title + autocomplete
// -------------------------------------------------------------------------
const header = document.createElement("div");
header.style.cssText = `
display:flex;
/* grid-template-columns:minmax(130px, 1fr) minmax(180px, 240px); */
justify-content:center;
align-items:center;
gap:14px;
min-height:42px;
margin-bottom:5px;
`;
/* const heading = document.createElement("h3");
heading.style.cssText = `
min-width:0;
margin:0;
overflow:hidden;
color:#292929;
font-size:clamp(17px, 1.45vw, 22px);
font-weight:700;
line-height:1.15;
text-overflow:ellipsis;
white-space:nowrap;
`; */
const searchWrapper = document.createElement("div");
searchWrapper.style.cssText = `
position:relative;
width:100%;
z-index:50;
`;
const searchControl = document.createElement("div");
searchControl.style.cssText = `
position:relative;
display:flex;
align-items:center;
width:100%;
height:34px;
box-sizing:border-box;
border-top:1px solid #e4e4e4;
border-bottom:1px solid #e4e4e4;
background:#fff;
transition:
border-color 120ms ease,
box-shadow 120ms ease;
`;
const searchInput = document.createElement("input");
searchInput.type = "text";
searchInput.placeholder = "City, Country";
searchInput.autocomplete = "off";
searchInput.setAttribute("role", "combobox");
searchInput.setAttribute("aria-autocomplete", "list");
searchInput.setAttribute("aria-expanded", "false");
searchInput.style.cssText = `
width:100%;
height:32px;
box-sizing:border-box;
padding:0 34px 0 32px;
border:none;
outline:none;
background:transparent;
color:#3c454b;
font:
italic bold 2rem,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
`;
const arrowButton = document.createElement("button");
arrowButton.type = "button";
arrowButton.title = "Show cities";
arrowButton.setAttribute("aria-label", "Show cities");
arrowButton.style.cssText = `
position:absolute;
right:3px;
top:50%;
display:flex;
align-items:center;
justify-content:center;
width:28px;
height:28px;
padding:0;
transform:translateY(-50%);
border:none;
border-radius:50%;
background:transparent;
color:#66737a;
cursor:pointer;
`;
arrowButton.innerHTML = `
<svg
viewBox="0 0 24 24"
width="17"
height="17"
aria-hidden="true"
>
<path
d="M7.5 9.5L12 14L16.5 9.5"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
`;
const dropdown = document.createElement("div");
dropdown.setAttribute("role", "listbox");
dropdown.style.cssText = `
position:absolute;
top:calc(100% + 4px);
left:0;
right:0;
display:none;
max-height:290px;
overflow-y:auto;
box-sizing:border-box;
padding:5px 0;
border:1px solid rgba(0,0,0,0.12);
border-radius:4px;
background:#fff;
box-shadow:
0 5px 5px -3px rgba(0,0,0,0.20),
0 8px 10px 1px rgba(0,0,0,0.14),
0 3px 14px 2px rgba(0,0,0,0.12);
`;
searchControl.append(
searchInput,
arrowButton
);
searchWrapper.append(
searchControl,
dropdown
);
header.append(
/* heading, */
searchWrapper
);
// -------------------------------------------------------------------------
// Map
// -------------------------------------------------------------------------
const mapBox = document.createElement("div");
mapBox.style.cssText = `
position:relative;
width:100%;
aspect-ratio:1;
overflow:hidden;
box-sizing:border-box;
border:1px solid #e2e2e2;
border-radius:7px;
background:#fff;
`;
const detailImage = document.createElement("img");
detailImage.loading = "lazy";
detailImage.style.cssText = `
position:absolute;
inset:0;
width:100%;
height:100%;
object-fit:contain;
user-select:none;
pointer-events:none;
`;
// -------------------------------------------------------------------------
// Ring overlay
// -------------------------------------------------------------------------
const SVG_NS = "http://www.w3.org/2000/svg";
const overlay = document.createElementNS(
SVG_NS,
"svg"
);
overlay.setAttribute("viewBox", "0 0 1 1");
overlay.setAttribute("preserveAspectRatio", "none");
overlay.style.cssText = `
position:absolute;
inset:0;
width:100%;
height:100%;
`;
const circlePath = radius => `
M ${0.5 - radius} 0.5
a ${radius} ${radius} 0 1 0 ${2 * radius} 0
a ${radius} ${radius} 0 1 0 ${-2 * radius} 0
Z
`;
const zoneShapes = {
central:
circlePath(R.r3),
intermediate:
`${circlePath(R.r5)} ${circlePath(R.r3)}`,
distant:
`${circlePath(R.r93)} ${circlePath(R.r5)}`,
periurban:
`M0 0 H1 V1 H0 Z ${circlePath(R.r93)}`
};
const highlightLayers = {};
for (const zone of zoneKeys) {
const path = document.createElementNS(
SVG_NS,
"path"
);
path.setAttribute("d", zoneShapes[zone]);
path.setAttribute("fill", "transparent");
path.setAttribute("fill-rule", "evenodd");
path.setAttribute("pointer-events", "none");
highlightLayers[zone] = path;
overlay.append(path);
}
const hitSurface = document.createElementNS(
SVG_NS,
"rect"
);
hitSurface.setAttribute("x", 0);
hitSurface.setAttribute("y", 0);
hitSurface.setAttribute("width", 1);
hitSurface.setAttribute("height", 1);
hitSurface.setAttribute("fill", "transparent");
hitSurface.setAttribute("pointer-events", "all");
hitSurface.style.cursor = "crosshair";
let currentZone = null;
let filteredCities = [...cities];
let activeOptionIndex = -1;
function getSelectedCode() {
return side === "left"
? selectedLeft
: selectedRight;
}
function getOtherSelectedCode() {
return side === "left"
? selectedRight
: selectedLeft;
}
function setSelectedCode(cityCode) {
if (side === "left") {
selectedLeft = cityCode;
} else {
selectedRight = cityCode;
}
}
function zoneAt(event) {
const bounds = mapBox.getBoundingClientRect();
const dx =
(event.clientX - bounds.left) /
bounds.width -
0.5;
const dy =
(event.clientY - bounds.top) /
bounds.height -
0.5;
const radius = Math.hypot(dx, dy);
if (radius < R.r3) {
return "central";
}
if (radius < R.r5) {
return "intermediate";
}
if (radius < R.r93) {
return "distant";
}
return "periurban";
}
function clearZone() {
if (!currentZone) {
return;
}
highlightLayers[currentZone].setAttribute(
"fill",
"transparent"
);
currentZone = null;
}
function distanceText(zone, city) {
const km = city.km || {};
if (zone === "central") {
return km.r3 != null
? `within ${km.r3} km`
: "";
}
if (zone === "intermediate") {
return (
km.r3 != null &&
km.r5 != null
)
? `${km.r3}–${km.r5} km`
: "";
}
if (zone === "distant") {
return (
km.r5 != null &&
km.r9_3 != null
)
? `${km.r5}–${km.r9_3} km`
: "";
}
return km.r9_3 != null
? `beyond ${km.r9_3} km`
: "";
}
function showTooltip(zone) {
const city = byCve.get(getSelectedCode());
const zoneValues = city?.zones?.[zone];
if (!city || !zoneValues) {
tooltip.style.opacity = 0;
return;
}
const metadata = zoneMeta[zone];
const change =
zoneValues.p2020 -
zoneValues.p1990;
const percentage =
zoneValues.p1990 > 0
? Math.round(
change /
zoneValues.p1990 *
100
)
: null;
const color =
change < 0
? LOSS
: GAIN;
const sign =
change < 0
? "−"
: "+";
const distance =
distanceText(zone, city);
tooltip.innerHTML = `
<strong>${metadata.label}</strong>
<span style="color:#888">
(${metadata.range}${
distance
? ` · ${distance}`
: ""
})
</span>
<br>
1990: ${fmt(zoneValues.p1990)}
<br>
2020: ${fmt(zoneValues.p2020)}
<br>
<span
style="
color:${color};
font-weight:600;
"
>
Change:
${sign}${fmt(Math.abs(change))}
${
percentage != null
? ` (${sign}${Math.abs(percentage)}%)`
: ""
}
</span>
`;
tooltip.style.opacity = 1;
}
function moveTooltip(event) {
const containerBounds =
container.getBoundingClientRect();
const tooltipWidth =
tooltip.offsetWidth || 220;
const tooltipHeight =
tooltip.offsetHeight || 100;
let left =
event.clientX -
containerBounds.left +
14;
let top =
event.clientY -
containerBounds.top +
14;
if (
left + tooltipWidth >
containerBounds.width
) {
left =
event.clientX -
containerBounds.left -
tooltipWidth -
14;
}
if (
top + tooltipHeight >
containerBounds.height
) {
top =
event.clientY -
containerBounds.top -
tooltipHeight -
14;
}
tooltip.style.left =
`${Math.max(0, left)}px`;
tooltip.style.top =
`${Math.max(0, top)}px`;
}
hitSurface.addEventListener(
"mousemove",
event => {
const zone = zoneAt(event);
if (zone !== currentZone) {
clearZone();
currentZone = zone;
highlightLayers[zone].setAttribute(
"fill",
HILITE
);
showTooltip(zone);
}
moveTooltip(event);
}
);
hitSurface.addEventListener(
"mouseleave",
() => {
clearZone();
tooltip.style.opacity = 0;
}
);
overlay.append(hitSurface);
mapBox.append(
detailImage,
overlay
);
// -------------------------------------------------------------------------
// Color scale
// -------------------------------------------------------------------------
const colorBar = document.createElement("div");
colorBar.style.cssText = `
margin-top:8px;
`;
const gradient = document.createElement("div");
gradient.style.cssText = `
height:11px;
border:1px solid #e2e2e2;
border-radius:2px;
background:
linear-gradient(
90deg,
#7a2618,
#c65a43,
#f5f5f5,
#4e8ab2,
#173a5e
);
`;
const scaleValues = document.createElement("div");
scaleValues.style.cssText = `
display:flex;
justify-content:space-between;
margin-top:2px;
font-size:10.5px;
`;
const lowValue = document.createElement("span");
lowValue.style.cssText = `
color:${LOSS};
font-weight:600;
`;
const middleValue = document.createElement("span");
middleValue.textContent = "0";
middleValue.style.cssText = `
color:#888;
`;
const highValue = document.createElement("span");
highValue.style.cssText = `
color:${GAIN};
font-weight:600;
`;
scaleValues.append(
lowValue,
middleValue,
highValue
);
const scaleCaption = document.createElement("div");
scaleCaption.textContent =
"population change per grid cell — loss ← → gain (scaled per city)";
scaleCaption.style.cssText = `
margin-top:1px;
color:#999;
font-size:0.6rem;
text-align:center;
`;
colorBar.append(
gradient,
scaleValues,
scaleCaption
);
// -------------------------------------------------------------------------
// Map description
// -------------------------------------------------------------------------
const caption = document.createElement("div");
caption.innerHTML = `
Population change by grid cell, 2020 minus 1990.
Black dot: city centre; brown/red = loss near the core,
blue = gains in the periphery. Dashed rings mark the
remoteness zones.
`;
caption.style.cssText = `
margin-top:12px;
color:#666;
font-size:0.75rem;
line-height:1.45;
`;
// -------------------------------------------------------------------------
// Dropdown behavior
// -------------------------------------------------------------------------
function openDropdown() {
dropdown.style.display = "block";
searchInput.setAttribute(
"aria-expanded",
"true"
);
arrowButton.style.transform =
"translateY(-50%) rotate(180deg)";
searchControl.style.borderColor =
"#1976d2";
}
function closeDropdown() {
dropdown.style.display = "none";
searchInput.setAttribute(
"aria-expanded",
"false"
);
arrowButton.style.transform =
"translateY(-50%) rotate(0deg)";
searchControl.style.borderColor =
"#e4e4e4";
activeOptionIndex = -1;
}
function renderOptions() {
dropdown.replaceChildren();
if (!filteredCities.length) {
const empty = document.createElement("div");
empty.textContent = "No cities found";
empty.style.cssText = `
padding:11px 14px;
color:#777;
font-size:13px;
`;
dropdown.append(empty);
return;
}
filteredCities.forEach(
(city, index) => {
const option = document.createElement(
"button"
);
const unavailable =
city.cve === getOtherSelectedCode();
const currentlySelected =
city.cve === getSelectedCode();
const active =
index === activeOptionIndex;
option.type = "button";
option.disabled = unavailable;
option.setAttribute("role", "option");
option.setAttribute(
"aria-selected",
currentlySelected
? "true"
: "false"
);
option.style.cssText = `
display:flex;
align-items:center;
gap:10px;
width:100%;
min-height:44px;
box-sizing:border-box;
padding:6px 13px;
border:none;
background:${
active && !unavailable
? "rgba(25,118,210,0.10)"
: currentlySelected
? "rgba(25,118,210,0.06)"
: "#fff"
};
color:${
unavailable
? "#aaa"
: "#292929"
};
text-align:left;
cursor:${
unavailable
? "not-allowed"
: "pointer"
};
opacity:${
unavailable
? "0.55"
: "1"
};
font:inherit;
`;
const thumbnail =
document.createElement("img");
thumbnail.src = city.thumb;
thumbnail.alt = "";
thumbnail.loading = "lazy";
thumbnail.style.cssText = `
flex:0 0 32px;
width:32px;
height:32px;
border-radius:3px;
object-fit:cover;
background:#f3f5f7;
`;
const label =
document.createElement("span");
label.textContent =
unavailable
? `${city.name} · selected`
: city.name;
label.style.cssText = `
flex:1;
min-width:0;
overflow:hidden;
font-size:13px;
line-height:1.2;
text-overflow:ellipsis;
white-space:nowrap;
`;
option.append(
thumbnail,
label
);
option.type = "button";
if (!unavailable) {
option.addEventListener("mouseenter", () => {
activeOptionIndex = index;
for (const item of dropdown.querySelectorAll("button")) {
const itemIndex = Number(item.dataset.index);
const itemCity = filteredCities[itemIndex];
if (!itemCity) continue;
const isSelected =
itemCity.cve === getSelectedCode();
const isUnavailable =
itemCity.cve === getOtherSelectedCode();
item.style.background =
itemIndex === activeOptionIndex && !isUnavailable
? "rgba(25,118,210,0.10)"
: isSelected
? "rgba(25,118,210,0.06)"
: "#fff";
}
});
option.addEventListener(
"mousedown",
event => {
event.preventDefault();
}
);
option.addEventListener(
"click",
() => {
selectCity(city.cve);
closeDropdown();
}
);
}
dropdown.append(option);
}
);
}
function filterOptions() {
const query =
normalizeText(searchInput.value);
filteredCities = cities.filter(city =>
normalizeText(city.name).includes(query)
);
activeOptionIndex =
filteredCities.findIndex(
city =>
city.cve !== getOtherSelectedCode()
);
renderOptions();
openDropdown();
}
function restoreSelectedName() {
const city =
byCve.get(getSelectedCode());
searchInput.value =
city?.name ?? "";
}
function selectCity(cityCode) {
if (
!byCve.has(cityCode) ||
cityCode === getOtherSelectedCode()
) {
return;
}
setSelectedCode(cityCode);
const city = byCve.get(cityCode);
const cityVmax = vmax[cityCode];
/* heading.textContent = city.name; */
searchInput.value = city.name;
detailImage.src = city.detail;
detailImage.alt =
`Population change map for ${city.name}, 1990 to 2020`;
lowValue.textContent =
cityVmax != null
? `−${fmt(cityVmax)}`
: "";
highValue.textContent =
cityVmax != null
? `+${fmt(cityVmax)}`
: "";
tooltip.style.opacity = 0;
/*
* Update both dropdowns immediately so the newly selected city
* becomes unavailable in the opposite selector.
*/
leftPanel.refreshDropdown();
rightPanel.refreshDropdown();
drawDensity();
highlightDens();
}
searchInput.addEventListener(
"focus",
() => {
searchInput.select();
filteredCities = [...cities];
activeOptionIndex = -1;
renderOptions();
openDropdown();
}
);
searchInput.addEventListener(
"input",
filterOptions
);
searchInput.addEventListener(
"keydown",
event => {
if (event.key === "ArrowDown") {
event.preventDefault();
if (
dropdown.style.display === "none"
) {
filteredCities = [...cities];
renderOptions();
openDropdown();
}
let next =
activeOptionIndex;
do {
next = Math.min(
next + 1,
filteredCities.length - 1
);
} while (
filteredCities[next] &&
filteredCities[next].cve ===
getOtherSelectedCode() &&
next <
filteredCities.length - 1
);
activeOptionIndex = next;
renderOptions();
}
if (event.key === "ArrowUp") {
event.preventDefault();
let previous =
activeOptionIndex;
do {
previous = Math.max(
previous - 1,
0
);
} while (
filteredCities[previous] &&
filteredCities[previous].cve ===
getOtherSelectedCode() &&
previous > 0
);
activeOptionIndex = previous;
renderOptions();
}
if (
event.key === "Enter" &&
activeOptionIndex >= 0
) {
const city =
filteredCities[
activeOptionIndex
];
if (
city &&
city.cve !== getOtherSelectedCode()
) {
event.preventDefault();
selectCity(city.cve);
closeDropdown();
}
}
if (event.key === "Escape") {
closeDropdown();
restoreSelectedName();
searchInput.blur();
}
}
);
arrowButton.addEventListener(
"click",
event => {
event.stopPropagation();
const open =
dropdown.style.display === "block";
if (open) {
closeDropdown();
return;
}
filteredCities = [...cities];
activeOptionIndex = -1;
renderOptions();
openDropdown();
searchInput.focus();
}
);
document.addEventListener(
"mousedown",
event => {
if (
!searchWrapper.contains(event.target)
) {
closeDropdown();
restoreSelectedName();
}
}
);
panel.append(
header,
mapBox,
colorBar,
caption
);
return {
node: panel,
selectCity,
refreshDropdown() {
if (
dropdown.style.display === "block"
) {
renderOptions();
}
}
};
}
// ---- density-change line plots (paper Fig 3): toggle local σ vs. average σ̄ density ----
const HL_DENS = "#e6550d";
const HL_DENS_RIGHT = "#3182bd";
let densMode = "point"; // "point" = local density σ ; "avg" = average density σ̄ within r
const densPanel = document.createElement("div");
densPanel.style.cssText = "margin-top:1vh;";
const densTitle = document.createElement("div");
densTitle.style.cssText = "font-size:1.15rem;font-weight:600;color:#333;margin-bottom:5px;";
densTitle.textContent = "Density change, 1990 → 2020";
densPanel.append(densTitle);
// toggle: local (point) vs average density
const densToggle = document.createElement("div");
densToggle.style.cssText = "display:inline-flex;border:1px solid #ccc;border-radius:5px;overflow:hidden;margin-bottom:5px;";
const modeBtn = {};
for (const [mode, label] of [["point", "Local"], ["avg", "Average"]]) {
const b = document.createElement("button");
b.textContent = label;
b.style.cssText = "border:none;background:#fff;color:#555;padding:3px 13px;font:500 11px system-ui,sans-serif;cursor:pointer;";
b.onclick = () => { densMode = mode; drawDensity(); };
modeBtn[mode] = b;
densToggle.append(b);
}
const densExplain = document.createElement("div");
densExplain.style.cssText = "font-size:10.5px;color:#999;margin-bottom:2px;";
densPanel.append(densToggle, densExplain);
const densCap = document.createElement("div");
densCap.style.cssText = "font-size:11.5px;color:#999;margin-top:4px;line-height:1.4;";
const DW = 400, DH = 208, dm = { top: 8, right: 12, bottom: 30, left: 48 };
const diw = DW - dm.left - dm.right, dih = DH - dm.top - dm.bottom;
const dx = d3.scaleLinear().domain([0, 10]).range([dm.left, DW - dm.right]);
const dy = d3.scaleLinear().domain([-12500, 6000]).range([DH - dm.bottom, dm.top]);
const dsvg = d3.create("svg")
.attr("viewBox", `0 0 ${DW} ${DH}`).attr("width", "35vw")
.style("height", "auto")
.style("margin-top", "2vh")
.style("font-family", "system-ui, sans-serif");
dsvg.append("defs").append("clipPath").attr("id", "dens-clip").append("rect")
.attr("x", dm.left).attr("y", dm.top).attr("width", diw).attr("height", dih);
dsvg.append("g").attr("transform", `translate(0,${DH - dm.bottom})`)
.call(d3.axisBottom(dx).ticks(6).tickSize(-dih))
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line").attr("stroke", "#000").attr("stroke-opacity", 0.06))
.call(g => g.selectAll(".tick text").attr("font-size", 9).attr("fill", "#666"));
dsvg.append("g").attr("transform", `translate(${dm.left},0)`)
.call(d3.axisLeft(dy).ticks(5).tickSize(-diw).tickFormat(d3.format("~s")))
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line").attr("stroke", "#000").attr("stroke-opacity", 0.06))
.call(g => g.selectAll(".tick text").attr("font-size", 9).attr("fill", "#666"));
dsvg.append("line").attr("x1", dm.left).attr("x2", DW - dm.right)
.attr("y1", dy(0)).attr("y2", dy(0)).attr("stroke", "#888").attr("stroke-dasharray", "3 3");
dsvg.append("text").attr("x", dm.left + diw / 2).attr("y", DH - 4).attr("text-anchor", "middle")
.attr("font-size", 10).attr("fill", "#555").text("remoteness r");
const dyLabel = dsvg.append("text").attr("transform", "rotate(-90)")
.attr("x", -(dm.top + dih / 2)).attr("y", 11)
.attr("text-anchor", "middle").attr("font-size", 10).attr("fill", "#555");
const dContent = dsvg.append("g").attr("clip-path", "url(#dens-clip)");
const densLine = new Map();
for (const cve of Object.keys(density.point.cities)) {
densLine.set(cve, dContent.append("path")
.attr("fill", "none").attr("stroke", "#bbb").attr("stroke-width", 1).attr("stroke-opacity", 0.22));
}
const natPath = dContent.append("path").attr("fill", "none").attr("stroke", "#111").attr("stroke-width", 2.5);
densPanel.append(dsvg.node(), densCap);
// (re)draw all density lines for the current mode, then re-apply the selection highlight
function drawDensity() {
const ds = density[densMode];
const cityLine = d3.line().x((v, i) => dx(ds.r[i])).y(v => dy(v));
for (const [cve, p] of densLine) p.attr("d", cityLine(ds.cities[cve]));
const nx = ds.national.x || ds.r;
natPath.attr("d", d3.line().x((v, i) => dx(nx[i])).y(v => dy(v))(ds.national.y));
dyLabel.text(densMode === "point" ? "Δσ (people/km²)" : "Δσ̄ (people/km²)");
densExplain.textContent = densMode === "point"
? "density right at distance r (each ring)"
: "average density of everything within distance r";
for (const m of ["point", "avg"]) {
const on = m === densMode;
modeBtn[m].style.background = on ? "#143d57" : "#fff";
modeBtn[m].style.color = on ? "#fff" : "#555";
}
highlightDens();
}
// highlight the selected city's density line (orange), keep the national trend on top
function highlightDens() {
for (const [, p] of densLine) p.attr("stroke", "#bbb").attr("stroke-width", 1).attr("stroke-opacity", 0.22);
const dsel = densLine.get(selectedLeft);
const dselRight = densLine.get(selectedRight);
if (dsel) dsel.attr("stroke", HL_DENS).attr("stroke-width", 2.5).attr("stroke-opacity", 1).raise();
if (dselRight) dselRight.attr("stroke", HL_DENS_RIGHT).attr("stroke-width", 2.5).attr("stroke-opacity", 1).raise();
natPath.raise();
const nm = byCve.get(selectedLeft);
const nmRight = byCve.get(selectedRight);
densCap.innerHTML =
`<span style="color:${HL_DENS};font-weight:600">${nm ? nm.name : ""}</span> • ` +
`<span style="color:${HL_DENS_RIGHT};font-weight:600">${nmRight ? nmRight.name : ""}</span> • ` +
`<span style="font-weight:700; color:black">National trend</span> • <span style="font-weight:700; color:#909090">Other cities</span>`;
}
// ---------------------------------------------------------------------------
// Create both panels
// ---------------------------------------------------------------------------
const leftPanel = createCityPanel("left");
const rightPanel = createCityPanel("right");
container.append(
leftPanel.node,
versus,
rightPanel.node,
tooltip
);
container2.append(densPanel);
global_container.append(container, container2);
leftPanel.selectCity(selectedLeft);
rightPanel.selectCity(selectedRight);
// ---------------------------------------------------------------------------
// Responsive layout
// ---------------------------------------------------------------------------
const mediaQuery = window.matchMedia(
"(max-width: 850px)"
);
function updateLayout(event) {
if (event.matches) {
container.style.gridTemplateColumns =
"minmax(280px, 520px)";
container.style.rowGap = "28px";
versus.style.paddingTop = "0";
} else {
container.style.gridTemplateColumns =
"minmax(280px, 520px) auto minmax(280px, 520px)";
container.style.rowGap = "0";
versus.style.paddingTop = "20px";
}
}
updateLayout(mediaQuery);
mediaQuery.addEventListener(
"change",
updateLayout
);
return global_container;
}Cities grow by stretching: Find where your city is
We found that cities expand in a predictable way: the population distribution at one date is simply the earlier distribution stretched outward.
A city’s later population map can be recovered by taking its earlier map and simply stretching it outward. The single number that measures that stretch is the urban expansion factor Φ — how far the population distribution has been pulled away from the centre between two dates.
figure3 = {
const data = await FileAttachment("assets/data/figure6_points.csv").csv({ typed: true });
const rdens = await FileAttachment("assets/data/radial_density.json").json();
const periodColors = { "1990–2000": "#E6AB04", "2000–2010": "#B85A0D", "2010–2020": "#878372" };
const periods = ["1990–2000", "2000–2010", "2010–2020"];
const C1990 = "#8fb0c4", C2020 = "#0f3041", CANIM = "#e6550d";
const byCity = d3.group(data, d => d.city_name);
const cities = Array.from(byCity.keys()).sort((a, b) => a.localeCompare(b, "es"));
const cityCve = new Map(Array.from(byCity, ([n, rows]) => [n, rows[0].cve_code]));
// cumulative population growth P(2020)/P(1990) per city = product of the 3 intercensal
// growth factors; its square root is the density-preserving counterfactual Φ (Φ = √growth).
const cityCumGrowth = new Map(Array.from(byCity, ([n, rows]) => [n, rows.reduce((a, r) => a * r.growth_factor, 1)]));
let selected = "Ciudad de México";
const container = document.createElement("div");
container.style.cssText = "position:relative;display:flex;flex-wrap:wrap;gap:22px;align-items:flex-start;font-family:system-ui,sans-serif;margin-bottom:2.5vh;";
const left = document.createElement("div");
left.style.cssText = "flex:1 1 540px;max-width:33vw;";
const right = document.createElement("div");
right.style.cssText = "flex:1 1 320px;max-width:33vw;";
// ---- geometry (margins baked into the scale ranges so d3.zoom rescaling is exact) ----
const W = 640, H = 470, m = { top: 22, right: 22, bottom: 50, left: 56 };
const W_fig6 = 640, H_fig6 = 470, m_fig6 = { top: 22, right: 22, bottom: 50, left: 56 };
const iw = W - m.left - m.right, ih = H - m.top - m.bottom;
const iw_fig6 = W_fig6 - m_fig6.left - m_fig6.right, ih_fig6 = H_fig6 - m_fig6.top - m_fig6.bottom;
const x = d3.scaleLinear().domain([0, 3.5]).range([m.left, W - m.right]);
const x_fig6 = d3.scaleLinear().domain([0, 3.5]).range([m_fig6.left, W_fig6 - m_fig6.right]);
const y = d3.scaleLinear().domain([0, 2.25]).range([H - m.bottom, m.top]);
const y_fig6 = d3.scaleLinear().domain([0, 2.25]).range([H_fig6 - m_fig6.bottom, m_fig6.top]);
const XMAX_fig6 = x_fig6.domain()[1], YMAX_fig6 = y_fig6.domain()[1];
// ---- Φ slider with empirical-Φ̂ marker ----
const sliderWrap = document.createElement("div");
sliderWrap.style.cssText = "position:relative;margin:20px 0 2px;";
const markRow = document.createElement("div");
markRow.style.cssText = "position:relative;height:26px;";
const marker = document.createElement("div");
marker.style.cssText = "position:absolute;transform:translateX(-50%);text-align:center;bottom:-2px;font-size:11px;color:#c0392b;font-weight:600;line-height:1.05;white-space:nowrap;pointer-events:none;";
markRow.append(marker);
const slider = document.createElement("input");
slider.type = "range"; slider.step = "0.01";
slider.style.cssText = "width:100%;accent-color:#e6550d;display:block;margin:0;";
// counterfactual (density-preserving) Φ marker — sits below the slider pointing up (▴),
// grey to match the phase-space "constant density Φ = √growth" diagonal.
const markRowCf = document.createElement("div");
markRowCf.style.cssText = "position:relative;height:34px;";
const markerCf = document.createElement("div");
markerCf.style.cssText = "position:absolute;transform:translateX(-50%);text-align:center;top:-2px;font-size:11px;color:#555;font-weight:600;line-height:1.05;white-space:nowrap;pointer-events:none;";
markRowCf.append(markerCf);
const sliderTicks = document.createElement("div");
sliderTicks.style.cssText = "display:flex;justify-content:space-between;font-size:10.5px;color:#999;margin-top:1px;";
const phiReadout = document.createElement("div");
phiReadout.style.cssText = "font:600 13px system-ui;color:#333;margin-top:5px;";
sliderWrap.append(markRow, slider, markRowCf, sliderTicks, phiReadout);
right.append(sliderWrap);
// --- stretchdemo chart
const svg = d3.create("svg").attr("viewBox", `0 0 ${W} ${H}`).attr("width", "100%")
.style("max-width", W + "px").style("height", "auto").style("font-family", "system-ui, sans-serif");
svg.append("defs").append("clipPath").attr("id", "stretch-clip").append("rect")
.attr("x", m.left).attr("y", m.top).attr("width", iw).attr("height", ih);
const gx = svg.append("g").attr("transform", `translate(0,${H - m.bottom})`);
const gy = svg.append("g").attr("transform", `translate(${m.left},0)`);
svg.append("text").attr("x", m.left + iw / 2).attr("y", H - 3).attr("text-anchor", "middle")
.attr("font-size", 11).attr("fill", "#555").text("remoteness r (scaled distance from centre)");
svg.append("text").attr("transform", "rotate(-90)").attr("x", -(m.top + ih / 2)).attr("y", 13)
.attr("text-anchor", "middle").attr("font-size", 11).attr("fill", "#555").text("population density ρ");
const content = svg.append("g").attr("clip-path", "url(#stretch-clip)");
const pathFor = (xs, ys) => d3.line().x((_, i) => x(xs[i])).y((_, i) => y(ys[i]))(ys);
const p2020 = content.append("path").attr("fill", "none").attr("stroke", C2020).attr("stroke-width", 3.5).attr("stroke-opacity", 0.22);
const p1990 = content.append("path").attr("fill", "none").attr("stroke", C1990).attr("stroke-width", 1.8);
const panim = content.append("path").attr("fill", "none").attr("stroke", CANIM).attr("stroke-width", 2.6);
// legend
const legend = document.createElement("div");
legend.style.cssText = "display:flex;gap:16px;flex-wrap:wrap;font-size:11.5px;color:#555;margin-top:4px;";
const legItem = (color, text, op) => {
const s = document.createElement("span"); s.style.cssText = "display:inline-flex;align-items:center;gap:5px;";
s.innerHTML = `<span style="display:inline-block;width:16px;height:3px;background:${color};opacity:${op || 1};border-radius:2px;"></span>${text}`;
return s;
};
legend.append(legItem(C1990, "1990"), legItem(CANIM, "1990 stretched by Φ"), legItem(C2020, "2020 (observed)", 0.3));
right.append(svg.node(), legend);
// ---- state + drawing ----
let cur = null, phiHat = 1, sMin = 0.8, sMax = 3;
function redraw(phi) {
p1990.attr("d", pathFor(cur.y1990.x, cur.y1990.y));
p2020.attr("d", pathFor(cur.y2020.x, cur.y2020.y));
const ax = cur.y1990.x.map(v => v * phi), ay = cur.y1990.y.map(v => v / phi);
panim.attr("d", pathFor(ax, ay));
if (+slider.value !== phi) slider.value = phi;
const near = Math.abs(phi - phiHat) < 0.02;
phiReadout.innerHTML = `Φ = <span style="color:#e6550d">${phi.toFixed(2)}</span>` +
(near ? ` <span style="color:#2e7d32;font-weight:600">✓ matches the observed 2020 curve</span>` : "");
}
// ---- container ----
container.append(left, right);
// ---- controls ----
const controls = document.createElement("div");
controls.style.cssText = "display:flex;flex-wrap:wrap;gap:10px 18px;align-items:center;margin-bottom:8px;";
const lbl = document.createElement("label");
lbl.style.cssText = "font-size:14px;font-weight:600;display:flex;gap:8px;align-items:center;";
lbl.append("Select a city:");
const select = document.createElement("select");
select.style.cssText = "font:inherit;padding:4px 8px;border-radius:6px;border:1px solid #bbb;background:#fff;";
for (const c of cities) {
const o = document.createElement("option");
o.value = c; o.textContent = c;
select.append(o);
}
lbl.append(select);
const resetBtn = document.createElement("button");
resetBtn.type = "button";
resetBtn.textContent = "Reset view";
resetBtn.style.cssText = "font:inherit;font-size:13px;padding:4px 10px;border-radius:6px;border:1px solid #bbb;background:#f6f6f6;cursor:pointer;color:#000000;";
controls.append(lbl, resetBtn);
const legend_years = document.createElement("div");
legend_years.style.cssText = "display:flex;gap:14px;font-size:12.5px;color:#333;";
for (const p of periods) {
const it = document.createElement("span");
it.style.cssText = "display:inline-flex;align-items:center;gap:5px;";
it.innerHTML = `<span style="width:11px;height:11px;border-radius:50%;background:${periodColors[p]};display:inline-block;"></span>${p}`;
legend_years.append(it);
}
controls.append(legend_years);
left.append(controls);
// ---- svg scaffold ----
const svg_fig6 = d3.create("svg")
.attr("viewBox", `0 0 ${W_fig6} ${H_fig6}`).attr("width", "100%")
.style("max-width", W_fig6 + "px").style("height", "auto")
.style("font-family", "system-ui, sans-serif").style("cursor", "grab");
const clipId = "fig6-clip";
svg_fig6.append("defs").append("clipPath").attr("id", clipId).append("rect")
.attr("x", m_fig6.left).attr("y", m_fig6.top).attr("width", iw_fig6).attr("height", ih_fig6);
const gx_fig6 = svg_fig6.append("g").attr("transform", `translate(0,${H_fig6 - m_fig6.bottom})`); // x axis (fixed)
const gy_fig6 = svg_fig6.append("g").attr("transform", `translate(${m_fig6.left},0)`); // y axis (fixed)
const content_fig6 = svg_fig6.append("g").attr("clip-path", `url(#${clipId})`); // zoomable
// ---- the six phase regions (A–F), bounded by growth=1, Φ=1, Φ=√growth ----
// teal shades = density loss (above √growth); tan/brown = density gain (below).
const xsL = d3.range(0, 1.0001, 0.02); // population loss (growth < 1)
const xsR = d3.range(1, XMAX_fig6 + 0.0001, 0.02); // population growth (growth > 1)
const regions = [
{ xs:xsR, y0:d=>Math.sqrt(d), y1:()=>YMAX_fig6, color:"#bfe0d8",
lines:["Density loss","Urban expansion","Population growth"], lx:1.22, ly:2.12, anchor:"start" }, // B
{ xs:xsR, y0:()=>1, y1:d=>Math.sqrt(d), color:"#f2e6c2",
lines:["Density gain","Urban expansion","Population growth"], lx:XMAX_fig6-0.05, ly:1.22, anchor:"end" }, // C
{ xs:xsR, y0:()=>0, y1:()=>1, color:"#dcc07a",
lines:["Density gain","Urban compression","Population growth"], lx:XMAX_fig6-0.05, ly:0.6, anchor:"end" },// D
{ xs:xsL, y0:()=>1, y1:()=>YMAX_fig6, color:"#6fc0b7",
lines:["Density loss","Urban expansion","Population loss"], lx:0.05, ly:2.12, anchor:"start" }, // A
{ xs:xsL, y0:d=>Math.sqrt(d), y1:()=>1, color:"#3f8f86",
lines:["Density loss","Urban compression","Population loss"], lx:0.05, ly:0.8, anchor:"start" }, // E
{ xs:xsL, y0:()=>0, y1:d=>Math.sqrt(d), color:"#c79a5a",
lines:["Density gain","Urban compression","Population loss"], lx:0.97, ly:0.42, anchor:"end" }, // F
];
for (const r of regions)
r.fill = content_fig6.append("path").attr("fill", r.color).attr("fill-opacity", 0.22).attr("stroke", "none");
// boundary lines
const vLine = content_fig6.append("line").attr("stroke", "#8a8a8a").attr("stroke-dasharray", "4 4"); // growth = 1
const hLine = content_fig6.append("line").attr("stroke", "#8a8a8a").attr("stroke-dasharray", "4 4"); // Φ = 1
const diag = content_fig6.append("path").attr("fill", "none").attr("stroke", "#555")
.attr("stroke-width", 1.5).attr("stroke-dasharray", "6 4"); // Φ = √growth
// fitted log-linear model for the Mexican system (paper eq. L_factors):
// Φ = growth^β · e^(α·Δt), with β=0.60, α=0.0057; Δt=10 yr for these intercensal periods.
const BETA_FIT = 0.60, ALPHA_FIT = 0.0057, DT_FIT = 10;
const phiModel = g => Math.pow(g, BETA_FIT) * Math.exp(ALPHA_FIT * DT_FIT);
const trend = content_fig6.append("path").attr("fill", "none").attr("stroke", "#c0392b")
.attr("stroke-width", 2.2).attr("stroke-opacity", 0.95); // fitted trend
// region labels (three lines each)
for (const r of regions) {
r.label = content_fig6.append("text").attr("text-anchor", r.anchor)
.attr("font-size", 8.7).attr("fill", "#2b2b2b").style("pointer-events", "none");
r.lines.forEach((ln, i) => r.label.append("tspan")
.text(ln).attr("dy", i === 0 ? 0 : "1.15em").attr("font-weight", i === 0 ? 700 : 400));
}
const diagLabel = content_fig6.append("text").attr("font-size", 9).attr("fill", "#555")
.attr("text-anchor", "end").style("pointer-events", "none").text("constant density Φ = √growth");
const trendLabel = content_fig6.append("text").attr("font-size", 9).attr("fill", "#c0392b")
.attr("font-weight", 600).attr("text-anchor", "end").style("pointer-events", "none")
.text("Mexican trend (β = 0.60, α = 0.0057)");
// connector across a selected city's three decades (below points)
const connector = content_fig6.append("path").attr("fill", "none").attr("stroke", "#111")
.attr("stroke-width", 1).attr("stroke-opacity", 0.55).attr("stroke-dasharray", "2 2");
// points
const pts = content_fig6.append("g").selectAll("circle").data(data).join("circle")
.attr("r", 4).attr("fill", d => periodColors[d.period])
.attr("stroke", "#fff").attr("stroke-width", 0.6).attr("opacity", 0.85)
.style("cursor", "pointer");
// axis titles + plot frame (fixed)
svg_fig6.append("rect").attr("x", m_fig6.left).attr("y", m_fig6.top).attr("width", iw_fig6).attr("height", ih_fig6)
.attr("fill", "none").attr("stroke", "#ddd");
svg_fig6.append("text").attr("x", m_fig6.left + iw_fig6 / 2).attr("y", H_fig6 - 10).attr("text-anchor", "middle")
.attr("font-size", 12.5).attr("fill", "#333").text("Population growth factor P(t₂)/P(t₁)");
svg_fig6.append("text").attr("transform", "rotate(-90)").attr("x", -(m_fig6.top + ih_fig6 / 2)).attr("y", 15)
.attr("text-anchor", "middle").attr("font-size", 12.5).attr("fill", "#333")
.text("Urban expansion factor Φ");
// ---- redraw for the current (possibly zoomed) scales ----
let curX = x_fig6, curY = y_fig6;
const diagXs = d3.range(0, XMAX_fig6 + 0.0001, 0.02);
function redraw_fig6(zx, zy) {
curX = zx; curY = zy;
gx_fig6.call(d3.axisBottom(zx).ticks(7).tickSize(-ih_fig6))
.call(s => s.select(".domain").remove())
.call(s => s.selectAll(".tick line").attr("stroke", "#000").attr("stroke-opacity", 0.06));
gy_fig6.call(d3.axisLeft(zy).ticks(6).tickSize(-iw_fig6))
.call(s => s.select(".domain").remove())
.call(s => s.selectAll(".tick line").attr("stroke", "#000").attr("stroke-opacity", 0.06));
for (const r of regions)
r.fill.attr("d", d3.area().x(d => zx(d)).y0(d => zy(r.y0(d))).y1(d => zy(r.y1(d)))(r.xs));
vLine.attr("x1", zx(1)).attr("x2", zx(1)).attr("y1", m_fig6.top).attr("y2", H_fig6 - m_fig6.bottom);
hLine.attr("y1", zy(1)).attr("y2", zy(1)).attr("x1", m_fig6.left).attr("x2", W_fig6 - m_fig6.right);
diag.attr("d", d3.line().x(d => zx(d)).y(d => zy(Math.sqrt(d)))(diagXs));
trend.attr("d", d3.line().x(d => zx(d)).y(d => zy(phiModel(d)))(diagXs));
for (const r of regions) {
r.label.attr("x", zx(r.lx)).attr("y", zy(r.ly));
r.label.selectAll("tspan").attr("x", zx(r.lx));
}
// place each curve label on its line, rotated parallel to the local tangent (in pixel space)
const labelAlong = (label, f, g0, off) => {
const px = zx(g0), py = zy(f(g0)), dg = 0.06;
const ang = Math.atan2(zy(f(g0 + dg)) - zy(f(g0 - dg)), zx(g0 + dg) - zx(g0 - dg)) * 180 / Math.PI;
label.attr("x", px).attr("y", py).attr("dy", off).attr("transform", `rotate(${ang},${px},${py})`);
};
labelAlong(diagLabel, Math.sqrt, 3.3, -5);
labelAlong(trendLabel, phiModel, 2.95, -5);
pts.attr("cx", d => zx(d.growth_factor)).attr("cy", d => zy(d.phi));
const seq = data.filter(d => d.city_name === selected)
.sort((a, b) => periods.indexOf(a.period) - periods.indexOf(b.period));
connector.attr("d", d3.line().x(d => zx(d.growth_factor)).y(d => zy(d.phi))(seq));
pts.filter(d => d.city_name === selected).raise();
}
// ---- tooltip ----
const tip = document.createElement("div");
tip.style.cssText = "position:absolute;pointer-events:none;background:#fff;border:1px solid #ccc;border-radius:6px;padding:6px 9px;font-size:12.5px;line-height:1.35;box-shadow:0 4px 14px rgba(0,0,0,.12);opacity:0;transition:opacity .1s;z-index:20;";
container.append(tip);
pts.on("mouseover", (e, d) => {
d3.select(e.currentTarget).attr("r", 7).attr("stroke", "#222").attr("stroke-width", 1).raise();
tip.style.opacity = 1;
tip.innerHTML = `<strong>${d.city_name}</strong><br>${d.period}<br>growth ${d.growth_factor.toFixed(2)}× · Φ ${d.phi.toFixed(2)}`;
})
.on("mousemove", (e) => {
const r = container.getBoundingClientRect();
tip.style.left = (e.clientX - r.left + 12) + "px";
tip.style.top = (e.clientY - r.top + 12) + "px";
})
.on("mouseout", (e, d) => {
const sel = d.city_name === selected;
d3.select(e.currentTarget).attr("r", sel ? 6.5 : 4)
.attr("stroke", sel ? "#111" : "#fff").attr("stroke-width", sel ? 1.5 : 0.6);
tip.style.opacity = 0;
})
.on("click", (e, d) => selectCity(d.city_name));
// ---- zoom & pan ----
const zoom = d3.zoom().scaleExtent([1, 20])
.extent([[m_fig6.left, m_fig6.top], [W_fig6 - m_fig6.right, H_fig6 - m_fig6.bottom]])
.translateExtent([[m_fig6.left, m_fig6.top], [W_fig6 - m_fig6.right, H_fig6 - m_fig6.bottom]])
.on("zoom", (e) => redraw_fig6(e.transform.rescaleX(x_fig6), e.transform.rescaleY(y_fig6)));
svg_fig6.call(zoom).on("dblclick.zoom", null);
resetBtn.addEventListener("click", () =>
svg_fig6.transition().duration(400).call(zoom.transform, d3.zoomIdentity));
const hint = document.createElement("div");
hint.style.cssText = "font-size:11.5px;color:#888;margin-top:4px;";
hint.textContent = "Scroll to zoom · drag to pan · click a point or use the menu to select a city.";
// chart_legend from svg file (assets/images/story/legend.svg)
let chart_legend = document.createElement("div");
chart_legend.style.cssText = "display:flex;gap:16px;flex-wrap:wrap;font-size:11.5px;color:#555;margin-top:1vh;justify-content:center;align-items:center;";
let chart_legend_svg_file = await FileAttachment("assets/images/story/legend_2.svg").text();
chart_legend.innerHTML = chart_legend_svg_file;
chart_legend.querySelectorAll("svg").forEach(svg => {
svg.style.cssText = "max-width:24vw;height:auto;display:block;";
});
left.append(svg_fig6.node(), hint, chart_legend);
// ---- selection ----
function selectCity(name) {
selected = name;
if (select.value !== name) select.value = name;
pts.attr("opacity", d => d.city_name === name ? 1 : 0.16)
.attr("r", d => d.city_name === name ? 6.5 : 4)
.attr("stroke", d => d.city_name === name ? "#111" : "#fff")
.attr("stroke-width", d => d.city_name === name ? 1.5 : 0.6);
const seq = data.filter(d => d.city_name === name)
.sort((a, b) => periods.indexOf(a.period) - periods.indexOf(b.period));
connector.attr("d", d3.line().x(d => curX(d.growth_factor)).y(d => curY(d.phi))(seq));
pts.filter(d => d.city_name === name).raise();
const rec = rdens[cityCve.get(name)];
cur = { y1990: rec.years["1990"], y2020: rec.years["2020"] };
phiHat = rec.G["2020"];
sMin = 0.8; sMax = Math.min(4, Math.max(2.5, phiHat + 1));
slider.min = sMin; slider.max = sMax; slider.value = 1;
x.domain([0, rec.xcap * 1.02]); // same ~99%-population cutoff as the phase-space density panel
y.domain([0, Math.max(d3.max(cur.y1990.y) / sMin, d3.max(cur.y2020.y)) * 1.08]);
gx.call(d3.axisBottom(x).ticks(7).tickSize(-ih)).call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line").attr("stroke", "#000").attr("stroke-opacity", 0.06))
.call(g => g.selectAll(".tick text").attr("font-size", 9).attr("fill", "#666"));
gy.call(d3.axisLeft(y).ticks(5).tickSize(-iw).tickFormat(() => "")).call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line").attr("stroke", "#000").attr("stroke-opacity", 0.06));
sliderTicks.innerHTML = `<span>Φ = ${sMin.toFixed(1)}</span><span>${sMax.toFixed(1)}</span>`;
const pct = Math.max(0, Math.min(100, (phiHat - sMin) / (sMax - sMin) * 100));
marker.style.left = pct + "%";
marker.innerHTML = `Φ̂ = ${phiHat.toFixed(2)}<br>▾`;
const phiCf = Math.sqrt(cityCumGrowth.get(name));
const pctCf = Math.max(0, Math.min(100, (phiCf - sMin) / (sMax - sMin) * 100));
markerCf.style.left = pctCf + "%";
markerCf.innerHTML = `▴<br>Φ (density-preserving)<br>= ${phiCf.toFixed(2)}`;
redraw(1);
}
select.addEventListener("change", () => selectCity(select.value));
redraw_fig6(x, y);
slider.addEventListener("input", () => redraw(+slider.value));
selectCity(selected);
return container;
}This is a plain-language visualization summary of the research article Peraza-Mues, G.G., Resendiz, E., Figueroa-Soriano, R., Prieto-Curiel, R., Ponce-Lopez, R. (2026) [Forthcoming] Scaling and Population Loss in Mexican Urban Centres. Nature Communications. (Accepted 30 June, 2026)
Affiliations:
Tecnológico de Monterrey (Center for the Future of Cities; School of Government and Public Transformation; School of Architecture, Art and Design)
Complexity Science Hub, Vienna.
This web summary and visualizations were created by Gonzalo G. Peraza-Mues, Eugen Resendiz, and Juan E. Díaz Noguez.