Files
ppf/static/map.js

407 lines
17 KiB
JavaScript

/**
* PPF Proxy Map - Interactive visualization
*/
(function() {
'use strict';
// Theme toggle (shared with dashboard)
var themes = ['dark', 'muted-dark', 'light'];
function getTheme() {
if (document.documentElement.classList.contains('light')) return 'light';
if (document.documentElement.classList.contains('muted-dark')) return 'muted-dark';
return 'dark';
}
function setTheme(theme) {
document.documentElement.classList.remove('light', 'muted-dark');
if (theme === 'light') document.documentElement.classList.add('light');
else if (theme === 'muted-dark') document.documentElement.classList.add('muted-dark');
try { localStorage.setItem('ppf-theme', theme); } catch(e) {}
}
function initTheme() {
var saved = null;
try { saved = localStorage.getItem('ppf-theme'); } catch(e) {}
if (saved && themes.indexOf(saved) !== -1) {
setTheme(saved);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
setTheme('light');
}
var btn = document.getElementById('themeToggle');
if (btn) {
btn.addEventListener('click', function() {
var current = getTheme();
var idx = themes.indexOf(current);
var next = themes[(idx + 1) % themes.length];
setTheme(next);
});
}
}
document.addEventListener('DOMContentLoaded', initTheme);
// Country coordinates (ISO 3166-1 alpha-2 -> [lat, lon])
var COORDS = {
"AD":[42.5,1.5],"AE":[24,54],"AF":[33,65],"AG":[17.05,-61.8],"AL":[41,20],
"AM":[40,45],"AO":[-12.5,18.5],"AR":[-34,-64],"AT":[47.33,13.33],"AU":[-27,133],
"AZ":[40.5,47.5],"BA":[44,18],"BB":[13.17,-59.53],"BD":[24,90],"BE":[50.83,4],
"BF":[13,-2],"BG":[43,25],"BH":[26,50.55],"BI":[-3.5,30],"BJ":[9.5,2.25],
"BN":[4.5,114.67],"BO":[-17,-65],"BR":[-10,-55],"BS":[24.25,-76],"BT":[27.5,90.5],
"BW":[-22,24],"BY":[53,28],"BZ":[17.25,-88.75],"CA":[60,-95],"CD":[-2.5,23.5],
"CF":[7,21],"CG":[-1,15],"CH":[47,8],"CI":[8,-5],"CL":[-30,-71],"CM":[6,12],
"CN":[35,105],"CO":[4,-72],"CR":[10,-84],"CU":[21.5,-80],"CV":[16,-24],
"CY":[35,33],"CZ":[49.75,15.5],"DE":[51,9],"DJ":[11.5,43],"DK":[56,10],
"DO":[19,-70],"DZ":[28,3],"EC":[-2,-77.5],"EE":[59,26],"EG":[27,30],
"ER":[15,39],"ES":[40,-4],"ET":[8,38],"FI":[64,26],"FJ":[-18,175],"FR":[46,2],
"GA":[-1,11.75],"GB":[54,-2],"GE":[42,43.5],"GH":[8,-2],"GM":[13.47,-16.57],
"GN":[11,-10],"GQ":[2,10],"GR":[39,22],"GT":[15.5,-90.25],"GW":[12,-15],
"GY":[5,-59],"HK":[22.25,114.17],"HN":[15,-86.5],"HR":[45.17,15.5],
"HT":[19,-72.42],"HU":[47,20],"ID":[-5,120],"IE":[53,-8],"IL":[31.5,34.75],
"IN":[20,77],"IQ":[33,44],"IR":[32,53],"IS":[65,-18],"IT":[42.83,12.83],
"JM":[18.25,-77.5],"JO":[31,36],"JP":[36,138],"KE":[-1,38],"KG":[41,75],
"KH":[13,105],"KM":[-12.17,44.25],"KP":[40,127],"KR":[37,127.5],"KW":[29.5,45.75],
"KZ":[48,68],"LA":[18,105],"LB":[33.83,35.83],"LK":[7,81],"LR":[6.5,-9.5],
"LS":[-29.5,28.5],"LT":[56,24],"LU":[49.75,6.17],"LV":[57,25],"LY":[25,17],
"MA":[32,-5],"MC":[43.73,7.42],"MD":[47,29],"ME":[42.5,19.3],"MG":[-20,47],
"MK":[41.83,22],"ML":[17,-4],"MM":[22,98],"MN":[46,105],"MO":[22.17,113.55],
"MR":[20,-12],"MT":[35.83,14.58],"MU":[-20.28,57.55],"MV":[3.25,73],
"MW":[-13.5,34],"MX":[23,-102],"MY":[2.5,112.5],"MZ":[-18.25,35],"NA":[-22,17],
"NE":[16,8],"NG":[10,8],"NI":[13,-85],"NL":[52.5,5.75],"NO":[62,10],
"NP":[28,84],"NZ":[-41,174],"OM":[21,57],"PA":[9,-80],"PE":[-10,-76],
"PG":[-6,147],"PH":[13,122],"PK":[30,70],"PL":[52,20],"PR":[18.25,-66.5],
"PS":[32,35.25],"PT":[39.5,-8],"PY":[-23,-58],"QA":[25.5,51.25],"RO":[46,25],
"RS":[44,21],"RU":[60,100],"RW":[-2,30],"SA":[25,45],"SC":[-4.58,55.67],
"SD":[15,30],"SE":[62,15],"SG":[1.37,103.8],"SI":[46.12,14.82],"SK":[48.67,19.5],
"SL":[8.5,-11.5],"SN":[14,-14],"SO":[10,49],"SR":[4,-56],"SS":[7,30],
"SV":[13.83,-88.92],"SY":[35,38],"SZ":[-26.5,31.5],"TD":[15,19],"TG":[8,1.17],
"TH":[15,100],"TJ":[39,71],"TM":[40,60],"TN":[34,9],"TO":[-20,-175],
"TR":[39,35],"TT":[11,-61],"TW":[23.5,121],"TZ":[-6,35],"UA":[49,32],
"UG":[1,32],"US":[38,-97],"UY":[-33,-56],"UZ":[41,64],"VE":[8,-66],
"VN":[16,106],"YE":[15,48],"ZA":[-29,24],"ZM":[-15,30],"ZW":[-20,30],"XK":[42.6,20.9]
};
// Country names
var NAMES = {
"AD":"Andorra","AE":"UAE","AF":"Afghanistan","AG":"Antigua","AL":"Albania",
"AM":"Armenia","AO":"Angola","AR":"Argentina","AT":"Austria","AU":"Australia",
"AZ":"Azerbaijan","BA":"Bosnia","BB":"Barbados","BD":"Bangladesh","BE":"Belgium",
"BF":"Burkina Faso","BG":"Bulgaria","BH":"Bahrain","BI":"Burundi","BJ":"Benin",
"BN":"Brunei","BO":"Bolivia","BR":"Brazil","BS":"Bahamas","BT":"Bhutan",
"BW":"Botswana","BY":"Belarus","BZ":"Belize","CA":"Canada","CD":"DR Congo",
"CF":"C. African Rep.","CG":"Congo","CH":"Switzerland","CI":"Ivory Coast",
"CL":"Chile","CM":"Cameroon","CN":"China","CO":"Colombia","CR":"Costa Rica",
"CU":"Cuba","CV":"Cape Verde","CY":"Cyprus","CZ":"Czechia","DE":"Germany",
"DJ":"Djibouti","DK":"Denmark","DO":"Dominican Rep.","DZ":"Algeria","EC":"Ecuador",
"EE":"Estonia","EG":"Egypt","ER":"Eritrea","ES":"Spain","ET":"Ethiopia",
"FI":"Finland","FJ":"Fiji","FR":"France","GA":"Gabon","GB":"United Kingdom",
"GE":"Georgia","GH":"Ghana","GM":"Gambia","GN":"Guinea","GQ":"Eq. Guinea",
"GR":"Greece","GT":"Guatemala","GW":"Guinea-Bissau","GY":"Guyana","HK":"Hong Kong",
"HN":"Honduras","HR":"Croatia","HT":"Haiti","HU":"Hungary","ID":"Indonesia",
"IE":"Ireland","IL":"Israel","IN":"India","IQ":"Iraq","IR":"Iran","IS":"Iceland",
"IT":"Italy","JM":"Jamaica","JO":"Jordan","JP":"Japan","KE":"Kenya",
"KG":"Kyrgyzstan","KH":"Cambodia","KM":"Comoros","KP":"North Korea",
"KR":"South Korea","KW":"Kuwait","KZ":"Kazakhstan","LA":"Laos","LB":"Lebanon",
"LK":"Sri Lanka","LR":"Liberia","LS":"Lesotho","LT":"Lithuania","LU":"Luxembourg",
"LV":"Latvia","LY":"Libya","MA":"Morocco","MC":"Monaco","MD":"Moldova",
"ME":"Montenegro","MG":"Madagascar","MK":"N. Macedonia","ML":"Mali","MM":"Myanmar",
"MN":"Mongolia","MO":"Macau","MR":"Mauritania","MT":"Malta","MU":"Mauritius",
"MV":"Maldives","MW":"Malawi","MX":"Mexico","MY":"Malaysia","MZ":"Mozambique",
"NA":"Namibia","NE":"Niger","NG":"Nigeria","NI":"Nicaragua","NL":"Netherlands",
"NO":"Norway","NP":"Nepal","NZ":"New Zealand","OM":"Oman","PA":"Panama",
"PE":"Peru","PG":"Papua New Guinea","PH":"Philippines","PK":"Pakistan",
"PL":"Poland","PR":"Puerto Rico","PS":"Palestine","PT":"Portugal","PY":"Paraguay",
"QA":"Qatar","RO":"Romania","RS":"Serbia","RU":"Russia","RW":"Rwanda",
"SA":"Saudi Arabia","SC":"Seychelles","SD":"Sudan","SE":"Sweden","SG":"Singapore",
"SI":"Slovenia","SK":"Slovakia","SL":"Sierra Leone","SN":"Senegal","SO":"Somalia",
"SR":"Suriname","SS":"South Sudan","SV":"El Salvador","SY":"Syria","SZ":"Eswatini",
"TD":"Chad","TG":"Togo","TH":"Thailand","TJ":"Tajikistan","TM":"Turkmenistan",
"TN":"Tunisia","TO":"Tonga","TR":"Turkey","TT":"Trinidad","TW":"Taiwan",
"TZ":"Tanzania","UA":"Ukraine","UG":"Uganda","US":"United States","UY":"Uruguay",
"UZ":"Uzbekistan","VE":"Venezuela","VN":"Vietnam","YE":"Yemen","ZA":"South Africa",
"ZM":"Zambia","ZW":"Zimbabwe","XK":"Kosovo"
};
// Anonymity color mapping (matches CSS variables)
var ANON_COLORS = {
elite: {fill: '#50c878', stroke: '#2d8a4e'},
anonymous: {fill: '#38bdf8', stroke: '#1d8acf'},
transparent: {fill: '#f97316', stroke: '#c2410c'},
unknown: {fill: '#6b7280', stroke: '#4b5563'}
};
// Heatmap gradient
var HEAT_GRADIENT = {
0.0: '#181f2a',
0.3: '#1d4e89',
0.5: '#38bdf8',
0.7: '#50c878',
1.0: '#7ee787'
};
// Map configuration
var MAP_CONFIG = {
center: [30, 10],
zoom: 2,
minZoom: 2,
maxZoom: 8,
tileUrl: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
tileAttribution: '© OSM © CARTO'
};
// Cluster configuration
var CLUSTER_CONFIG = {
showCoverageOnHover: false,
spiderfyOnMaxZoom: true,
disableClusteringAtZoom: 7,
maxClusterRadius: 50
};
// DOM element references
var $countryCount, $proxyCount, map, clusterGroup;
/**
* Initialize the map
*/
function init() {
$countryCount = document.getElementById('countryCount');
$proxyCount = document.getElementById('proxyCount');
// Set loading state
$countryCount.classList.add('loading');
$proxyCount.classList.add('loading');
// Create map
map = L.map('map', {
center: MAP_CONFIG.center,
zoom: MAP_CONFIG.zoom,
minZoom: MAP_CONFIG.minZoom,
maxZoom: MAP_CONFIG.maxZoom,
zoomControl: true,
worldCopyJump: true
});
// Add tile layer
L.tileLayer(MAP_CONFIG.tileUrl, {
attribution: MAP_CONFIG.tileAttribution,
subdomains: 'abcd',
maxZoom: 19
}).addTo(map);
// Create cluster group
clusterGroup = L.markerClusterGroup({
showCoverageOnHover: CLUSTER_CONFIG.showCoverageOnHover,
spiderfyOnMaxZoom: CLUSTER_CONFIG.spiderfyOnMaxZoom,
disableClusteringAtZoom: CLUSTER_CONFIG.disableClusteringAtZoom,
maxClusterRadius: CLUSTER_CONFIG.maxClusterRadius,
iconCreateFunction: createClusterIcon
});
// Load data
loadData();
}
/**
* Create cluster icon
*/
function createClusterIcon(cluster) {
var count = cluster.getChildCount();
var size = count >= 100 ? 'lg' : count >= 10 ? 'md' : 'sm';
var sizeMap = {sm: 32, md: 40, lg: 50};
return L.divIcon({
html: '<div class="cluster-icon cluster-' + size + '">' + count + '</div>',
className: 'cluster-wrapper',
iconSize: L.point(sizeMap[size], sizeMap[size])
});
}
/**
* Calculate brightness based on count (logarithmic scale)
*/
function calcBrightness(count, maxCount, minBrightness, maxBrightness) {
minBrightness = minBrightness || 0.3;
maxBrightness = maxBrightness || 1.0;
var range = maxBrightness - minBrightness;
return minBrightness + range * (Math.log(count + 1) / Math.log(maxCount + 1));
}
/**
* Calculate radius based on count
*/
function calcRadius(count, baseRadius, maxExtra, divisor) {
baseRadius = baseRadius || 3;
maxExtra = maxExtra || 7;
divisor = divisor || 5;
return baseRadius + Math.min(maxExtra, Math.sqrt(count / divisor));
}
/**
* Create popup content
*/
function createPopup(code, count, anon, lat, lon, isApprox) {
var name = NAMES[code] || code;
var anonLabel = anon ? anon.charAt(0).toUpperCase() + anon.slice(1) : '';
var coords = isApprox ? 'approx. location' :
(anonLabel ? anonLabel + ' &bull; ' : '') + lat.toFixed(1) + ', ' + lon.toFixed(1);
return '<div class="popup-header">' +
'<span class="popup-code">' + code + '</span>' +
'<span class="popup-name">' + name + '</span>' +
'</div>' +
'<div class="popup-count">' + count.toLocaleString() + ' proxies</div>' +
'<div class="popup-coords">' + coords + '</div>';
}
/**
* Load and render map data
*/
function loadData() {
Promise.all([
fetch('/api/locations').then(function(r) { return r.json(); }).catch(function() { return {locations: []}; }),
fetch('/api/countries').then(function(r) { return r.json(); })
]).then(function(results) {
var locations = results[0].locations || [];
var countries = results[1].countries || {};
renderData(locations, countries);
}).catch(function() {
$proxyCount.textContent = 'Error';
$proxyCount.style.color = '#ef4444';
});
}
/**
* Render map data
*/
function renderData(locations, countries) {
var entries = Object.entries(countries).sort(function(a, b) { return b[1] - a[1]; });
var total = entries.reduce(function(s, e) { return s + e[1]; }, 0);
// Update stats
$countryCount.textContent = entries.length;
$proxyCount.textContent = total.toLocaleString();
$countryCount.classList.remove('loading');
$proxyCount.classList.remove('loading');
// Track countries with precise locations
var countriesWithPrecise = {};
locations.forEach(function(l) {
countriesWithPrecise[l.country] = (countriesWithPrecise[l.country] || 0) + l.count;
});
// Add heatmap layer
renderHeatmap(locations);
// Add precise location markers
renderPreciseLocations(locations);
// Add country centroid markers
renderCountryCentroids(entries, countriesWithPrecise);
}
/**
* Render heatmap layer
*/
function renderHeatmap(locations) {
if (locations.length === 0) return;
var heatData = locations.map(function(l) {
var intensity = Math.min(l.count / 50, 1.0);
return [l.lat, l.lon, intensity];
});
L.heatLayer(heatData, {
radius: 25,
blur: 20,
maxZoom: 6,
max: 1.0,
minOpacity: 0.3,
gradient: HEAT_GRADIENT
}).addTo(map);
}
/**
* Render precise location markers
*/
function renderPreciseLocations(locations) {
if (locations.length === 0) return;
var maxCount = Math.max.apply(null, locations.map(function(l) { return l.count; })) || 1;
locations.forEach(function(l) {
var brightness = calcBrightness(l.count, maxCount);
var radius = calcRadius(l.count);
var colors = ANON_COLORS[l.anon] || ANON_COLORS.unknown;
var marker = L.circleMarker([l.lat, l.lon], {
radius: radius,
fillColor: colors.fill,
color: colors.stroke,
weight: 1,
opacity: brightness,
fillOpacity: brightness * 0.85
});
marker.bindPopup(createPopup(l.country, l.count, l.anon, l.lat, l.lon, false));
// Hover effects
marker.on('mouseover', function() {
this.setStyle({fillOpacity: 0.95, opacity: 1, weight: 2});
});
marker.on('mouseout', function() {
this.setStyle({fillOpacity: brightness * 0.85, opacity: brightness, weight: 1});
});
clusterGroup.addLayer(marker);
});
map.addLayer(clusterGroup);
}
/**
* Render country centroid markers (for proxies without precise coords)
*/
function renderCountryCentroids(entries, countriesWithPrecise) {
// Find max remaining for normalization
var maxRemaining = 1;
entries.forEach(function(e) {
var remaining = e[1] - (countriesWithPrecise[e[0]] || 0);
if (remaining > maxRemaining) maxRemaining = remaining;
});
entries.forEach(function(e) {
var code = e[0], count = e[1], coords = COORDS[code];
if (!coords) return;
var preciseInCountry = countriesWithPrecise[code] || 0;
var remaining = count - preciseInCountry;
if (remaining <= 0) return;
var brightness = calcBrightness(remaining, maxRemaining, 0.25, 0.9);
var radius = calcRadius(remaining, 4, 10, 10);
var circle = L.circleMarker([coords[0], coords[1]], {
radius: radius,
fillColor: '#38bdf8',
color: '#1d8acf',
weight: 1,
opacity: brightness,
fillOpacity: brightness * 0.7
}).addTo(map);
circle.bindPopup(createPopup(code, remaining, null, coords[0], coords[1], true));
// Hover effects
circle.on('mouseover', function() {
this.setStyle({fillOpacity: 0.9, opacity: 1});
});
circle.on('mouseout', function() {
this.setStyle({fillOpacity: brightness * 0.7, opacity: brightness});
});
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();