cables and maps
This commit is contained in:
338
make_internet_cables_map.py
Normal file
338
make_internet_cables_map.py
Normal file
@@ -0,0 +1,338 @@
|
||||
#!/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
|
||||
|
||||
import psycopg2
|
||||
|
||||
|
||||
DB_NAME = "data_centers"
|
||||
DC_TABLE = "public.us_dc_sample_geocoded"
|
||||
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
|
||||
id,
|
||||
coalesce(provider, ''),
|
||||
coalesce(facility_name, ''),
|
||||
coalesce(city, ''),
|
||||
coalesce(state_code, ''),
|
||||
longitude,
|
||||
latitude,
|
||||
coalesce(geocode_source, '')
|
||||
from {DC_TABLE}
|
||||
where longitude is not null and latitude is not null
|
||||
"""
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"provider": r[1],
|
||||
"facility_name": r[2],
|
||||
"city": r[3],
|
||||
"state_code": r[4],
|
||||
"lon": float(r[5]),
|
||||
"lat": float(r[6]),
|
||||
"geocode_source": 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:#1f77b4"></span>IM3_Existing_DataCenters</span></div>
|
||||
<div class="row"><span><span class="swatch" style="background:#2ca02c"></span>US Census Geocoder</span></div>
|
||||
<div class="row"><span><span class="swatch" style="background:#ff7f0e"></span>Nominatim/OpenStreetMap</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 === 'IM3_Existing_DataCenters') return '#1f77b4';
|
||||
if (source === 'US Census Geocoder') return '#2ca02c';
|
||||
if (source === 'Nominatim/OpenStreetMap') return '#ff7f0e';
|
||||
return '#7f7f7f';
|
||||
}
|
||||
|
||||
function esc(v) {
|
||||
return String(v == null ? '' : v)
|
||||
.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>')
|
||||
.replaceAll('"','"').replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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: '© 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.geocode_source),
|
||||
fillColor: colorForSource(p.geocode_source),
|
||||
fillOpacity: 0.85,
|
||||
weight: 0.8,
|
||||
});
|
||||
const title = p.facility_name || p.id;
|
||||
const provider = p.provider || '(unknown provider)';
|
||||
const cityState = [p.city, p.state_code].filter(Boolean).join(', ');
|
||||
m.bindPopup(`
|
||||
<strong>${esc(title)}</strong><br>
|
||||
Provider: ${esc(provider)}<br>
|
||||
Location: ${esc(cityState)}<br>
|
||||
Source: ${esc(p.geocode_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="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()
|
||||
Reference in New Issue
Block a user