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>
340 lines
11 KiB
Python
340 lines
11 KiB
Python
#!/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('&','&').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.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()
|