Files
data-centers/scripts/make_internet_cables_map.py
dadams 6db5e0fff8 Fix path references in scripts after reorganization
Update 8 scripts to use Path(__file__).parent.parent as PROJECT_ROOT
so they resolve data/, output/, and internet_cables/ relative to the
project root rather than the caller's working directory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:57:47 -07:00

340 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Render a Leaflet HTML map combining US data centers, submarine cables,
and city-level network-dominance points from PostGIS.
"""
import argparse
import json
import os
from pathlib import Path
import psycopg2
DB_NAME = "data_centers"
DC_TABLE = "public.master_data_centers"
CABLES_TABLE = "public.internet_cables"
CITY_TABLE = "public.internet_city_dominance"
def connect():
return psycopg2.connect(
host=os.environ["PGWEB_HOST"],
port=os.environ["PGWEB_PORT"],
user=os.environ["PGWEB_USER"],
password=os.environ["PGWEB_PASSWORD"],
dbname=DB_NAME,
)
def load_data_centers(conn):
with conn.cursor() as cur:
cur.execute(
f"""
select
master_id,
source,
coalesce(operator, ''),
coalesce(name, ''),
coalesce(city, ''),
coalesce(state, ''),
longitude,
latitude
from {DC_TABLE}
where longitude is not null and latitude is not null
"""
)
return [
{
"id": r[0],
"source": r[1],
"operator": r[2],
"name": r[3],
"city": r[4],
"state": r[5],
"lon": float(r[6]),
"lat": float(r[7]),
}
for r in cur.fetchall()
]
def load_cables(conn):
with conn.cursor() as cur:
cur.execute(
f"""
select
feature_id,
coalesce(cable_id, ''),
coalesce(name, ''),
coalesce(color, '#888888'),
coalesce(owners, ''),
rfs_year,
decommission_year,
length_km,
coalesce(url, ''),
ST_AsGeoJSON(geom)
from {CABLES_TABLE}
where geom is not null
"""
)
features = []
for r in cur.fetchall():
features.append(
{
"type": "Feature",
"geometry": json.loads(r[9]),
"properties": {
"feature_id": r[0],
"cable_id": r[1],
"name": r[2],
"color": r[3],
"owners": r[4],
"rfs_year": r[5],
"decommission_year": r[6],
"length_km": float(r[7]) if r[7] is not None else None,
"url": r[8],
},
}
)
return {"type": "FeatureCollection", "features": features}
def load_cities(conn, us_only=False):
where = "where geom is not null"
if us_only:
where += " and country = 'US'"
with conn.cursor() as cur:
cur.execute(
f"""
select
id,
coalesce(city, ''),
coalesce(country, ''),
coalesce(country_name, ''),
coalesce(region, ''),
physical_capacity_tbps,
logical_dominance_ips,
longitude,
latitude
from {CITY_TABLE}
{where}
"""
)
return [
{
"id": r[0],
"city": r[1],
"country": r[2],
"country_name": r[3],
"region": r[4],
"tbps": float(r[5]) if r[5] is not None else None,
"ips": int(r[6]) if r[6] is not None else None,
"lon": float(r[7]),
"lat": float(r[8]),
}
for r in cur.fetchall()
]
def render_html(data_centers, cables_geojson, cities, output_path):
payload = json.dumps(
{
"data_centers": data_centers,
"cables": cables_geojson,
"cities": cities,
}
)
html = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>US Data Centers + Submarine Cables</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
html, body { height: 100%; margin: 0; font-family: system-ui, -apple-system, Segoe UI, sans-serif; }
#layout { display: grid; grid-template-columns: 300px 1fr; height: 100%; }
#panel { padding: 14px; border-right: 1px solid #ddd; overflow: auto; background: #f8fafb; font-size: 13px; }
#map { height: 100%; width: 100%; }
h1 { margin: 0 0 8px; font-size: 18px; }
h2 { margin: 14px 0 6px; font-size: 13px; text-transform: uppercase; color: #555; letter-spacing: 0.04em; }
.row { display: flex; justify-content: space-between; padding: 2px 0; }
.swatch { width: 12px; height: 12px; display: inline-block; margin-right: 8px; vertical-align: middle; border: 1px solid #ccc; }
label.toggle { display: block; padding: 3px 0; cursor: pointer; }
@media (max-width: 900px) {
#layout { grid-template-columns: 1fr; grid-template-rows: 220px 1fr; }
#panel { border-right: 0; border-bottom: 1px solid #ddd; }
}
</style>
</head>
<body>
<div id="layout">
<div id="panel">
<h1>Data Centers + Cables</h1>
<div class="row"><span>Data centers</span><strong id="dcCount"></strong></div>
<div class="row"><span>Submarine cables</span><strong id="cableCount"></strong></div>
<div class="row"><span>City dominance pts</span><strong id="cityCount"></strong></div>
<h2>Layers</h2>
<label class="toggle"><input type="checkbox" id="tDc" checked> Data centers</label>
<label class="toggle"><input type="checkbox" id="tCables" checked> Submarine cables</label>
<label class="toggle"><input type="checkbox" id="tCities" checked> City dominance</label>
<h2>Data center source</h2>
<div class="row"><span><span class="swatch" style="background:#2ca02c"></span>merged (curated + OSM)</span></div>
<div class="row"><span><span class="swatch" style="background:#1f77b4"></span>curated only</span></div>
<div class="row"><span><span class="swatch" style="background:#ff7f0e"></span>osm only</span></div>
<div class="row"><span><span class="swatch" style="background:#7f7f7f"></span>other</span></div>
<h2>City dominance</h2>
<div class="row"><span><span class="swatch" style="background:#9b59b6;border-radius:50%"></span>Sized by physical Tbps</span></div>
</div>
<div id="map"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const DATA = __PAYLOAD__;
function colorForSource(source) {
if (source === 'merged') return '#2ca02c';
if (source === 'curated') return '#1f77b4';
if (source === 'osm') return '#ff7f0e';
return '#7f7f7f';
}
function esc(v) {
return String(v == null ? '' : v)
.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;')
.replaceAll('"','&quot;').replaceAll("'", '&#39;');
}
const map = L.map('map', { preferCanvas: true, worldCopyJump: true }).setView([20, -40], 3);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map);
const cableLayer = L.geoJSON(DATA.cables, {
style: f => ({
color: f.properties.color || '#888',
weight: 1.4,
opacity: 0.75,
}),
onEachFeature: (feature, layer) => {
const p = feature.properties;
const yrs = [p.rfs_year, p.decommission_year].filter(Boolean).join(' ');
layer.bindPopup(`
<strong>${esc(p.name)}</strong><br>
${p.url ? `<a href="${esc(p.url)}" target="_blank" rel="noopener">${esc(p.url)}</a><br>` : ''}
Owners: ${esc(p.owners)}<br>
${yrs ? `Years: ${esc(yrs)}<br>` : ''}
${p.length_km ? `Length: ${esc(p.length_km.toLocaleString())} km<br>` : ''}
ID: ${esc(p.cable_id || p.feature_id)}
`);
},
}).addTo(map);
const cityLayer = L.layerGroup();
for (const c of DATA.cities) {
const tbps = c.tbps || 0;
const radius = Math.max(2, Math.min(18, Math.sqrt(tbps) * 1.6));
const m = L.circleMarker([c.lat, c.lon], {
radius,
color: '#6c2a86',
fillColor: '#9b59b6',
fillOpacity: 0.45,
weight: 0.8,
});
m.bindPopup(`
<strong>${esc(c.city)}</strong> (${esc(c.country)})<br>
Region: ${esc(c.region)}<br>
Physical capacity: ${esc(tbps.toFixed ? tbps.toFixed(2) : tbps)} Tbps<br>
Logical dominance IPs: ${esc(c.ips ? c.ips.toLocaleString() : '')}
`);
cityLayer.addLayer(m);
}
cityLayer.addTo(map);
const dcLayer = L.layerGroup();
const dcBounds = [];
for (const p of DATA.data_centers) {
const m = L.circleMarker([p.lat, p.lon], {
radius: 3,
color: colorForSource(p.source),
fillColor: colorForSource(p.source),
fillOpacity: 0.85,
weight: 0.8,
});
const title = p.name || p.id;
const operator = p.operator || '(unknown operator)';
const cityState = [p.city, p.state].filter(Boolean).join(', ');
m.bindPopup(`
<strong>${esc(title)}</strong><br>
Operator: ${esc(operator)}<br>
Location: ${esc(cityState)}<br>
Source: ${esc(p.source)}
`);
dcLayer.addLayer(m);
dcBounds.push([p.lat, p.lon]);
}
dcLayer.addTo(map);
if (dcBounds.length) map.fitBounds(dcBounds, { padding: [30, 30], maxZoom: 5 });
function toggle(layer, on) {
if (on) { if (!map.hasLayer(layer)) layer.addTo(map); }
else { if (map.hasLayer(layer)) map.removeLayer(layer); }
}
document.getElementById('tDc').addEventListener('change', e => toggle(dcLayer, e.target.checked));
document.getElementById('tCables').addEventListener('change', e => toggle(cableLayer, e.target.checked));
document.getElementById('tCities').addEventListener('change', e => toggle(cityLayer, e.target.checked));
document.getElementById('dcCount').textContent = DATA.data_centers.length.toLocaleString();
document.getElementById('cableCount').textContent = DATA.cables.features.length.toLocaleString();
document.getElementById('cityCount').textContent = DATA.cities.length.toLocaleString();
</script>
</body>
</html>
"""
html = html.replace("__PAYLOAD__", payload)
with open(output_path, "w", encoding="utf-8") as f:
f.write(html)
def parse_args():
p = argparse.ArgumentParser(
description="Render a Leaflet map combining data centers, submarine cables, and city dominance."
)
p.add_argument("--output", default=str(Path(__file__).parent.parent / "output" / "data_centers_cables_map.html"))
p.add_argument(
"--us-cities-only",
action="store_true",
help="Restrict the city-dominance layer to country='US'.",
)
return p.parse_args()
def main():
args = parse_args()
conn = connect()
try:
dcs = load_data_centers(conn)
cables = load_cables(conn)
cities = load_cities(conn, us_only=args.us_cities_only)
finally:
conn.close()
render_html(dcs, cables, cities, args.output)
print(
f"wrote {len(dcs)} data centers, "
f"{len(cables['features'])} cables, "
f"{len(cities)} city points -> {args.output}"
)
if __name__ == "__main__":
main()