407 lines
17 KiB
JavaScript
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 + ' • ' : '') + 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();
|
|
}
|
|
})();
|