2 Commits

Author SHA1 Message Date
TianXuan Developer 427890d6fb fix: robust map init — wait for container to have dimensions
- Check container.offsetHeight before initializing Leaflet map
- Retry up to 10 times (150ms intervals) until container is visible
- Fix grid line coordinate order (lat,lon)
- Add try-catch around continent drawing
- Call invalidateSize() after initialization

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-23 20:59:53 +08:00
TianXuan Developer be1150e67c fix: real continent outlines, subnet-colored markers, click interaction
- Replace rough SVG with Leaflet polygon continents (recognizable shapes)
- Color markers by /24 subnet hash instead of cluster IDs
- Remove count bubbles and cluster numbers from map
- Fix click handlers with stopPropagation + bubblingMouseEvents:false
- Tooltips show IP, subnet, city, country, ISP
- Legend shows community size + subnet info

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-23 20:41:31 +08:00
@@ -1166,70 +1166,114 @@ function renderClusterCards(clusters) {
});
}
/* ── Step 4: Map (static background + grid clustering) ── */
/* ── Simplified continent outlines as Leaflet polygons ──── */
const CONTINENTS = [
// North America
[[75,-168],[72,-140],[70,-130],[65,-125],[62,-115],[58,-108],[55,-100],[50,-95],[45,-90],[40,-85],[35,-82],[30,-80],[25,-78],[20,-80],[15,-83],[10,-84],[8,-80],[9,-77],[12,-72],[15,-66],[18,-60],[22,-60],[28,-58],[32,-60],[35,-55],[34,-45],[35,-40],[42,-35],[48,-30],[52,-25],[55,-20],[58,-15],[62,-12],[68,-10],[72,-15],[75,-25],[78,-35],[80,-50],[82,-60],[83,-70],[84,-80],[82,-90],[80,-100],[78,-120],[76,-140],[75,-168]],
// Greenland
[[83,-50],[82,-25],[80,-18],[78,-15],[76,-18],[75,-25],[76,-35],[78,-45],[80,-55],[83,-60],[84,-55],[83,-50]],
// South America
[[12,-72],[10,-65],[8,-55],[5,-48],[0,-45],[-5,-40],[-8,-35],[-10,-30],[-12,-25],[-15,-20],[-18,-18],[-22,-20],[-25,-25],[-28,-30],[-32,-35],[-35,-38],[-38,-40],[-40,-45],[-45,-48],[-50,-50],[-54,-52],[-55,-48],[-54,-42],[-52,-38],[-48,-35],[-42,-32],[-38,-30],[-35,-28],[-30,-25],[-25,-22],[-20,-20],[-15,-23],[-10,-30],[-5,-38],[0,-44],[5,-50],[8,-58],[10,-65],[12,-72]],
// Europe
[[71,-25],[70,-10],[68,5],[65,12],[62,15],[58,15],[55,12],[52,8],[48,5],[45,0],[43,-5],[44,-8],[48,-10],[52,-12],[55,-15],[57,-20],[58,-25],[60,-30],[62,-35],[65,-30],[68,-25],[70,-20],[71,-25]],
// UK/Ireland
[[59,-8],[58,-2],[56,0],[54,-3],[52,-6],[51,-10],[53,-12],[55,-10],[57,-8],[59,-8]],
// Scandinavia
[[71,25],[70,18],[68,15],[65,14],[62,12],[60,8],[58,5],[56,8],[55,12],[56,18],[58,25],[60,30],[62,32],[65,30],[68,28],[70,25],[71,25]],
// Africa
[[37,-8],[35,-2],[33,5],[30,10],[28,15],[25,20],[22,28],[18,35],[15,38],[12,40],[8,42],[3,43],[-2,42],[-5,38],[-8,35],[-12,32],[-15,28],[-18,25],[-20,20],[-22,18],[-24,15],[-27,12],[-30,8],[-32,5],[-35,0],[-33,-5],[-28,-8],[-22,-10],[-18,-8],[-14,-5],[-10,-3],[-5,0],[0,-2],[5,-4],[10,-5],[15,-7],[20,-10],[25,-12],[30,-10],[35,-8],[37,-8]],
// Asia
[[72,55],[70,80],[68,110],[65,130],[62,145],[58,155],[55,160],[50,165],[45,168],[40,170],[35,172],[30,170],[28,165],[25,160],[22,155],[18,150],[15,145],[12,140],[10,135],[8,130],[5,125],[2,120],[0,115],[-2,110],[-5,105],[-8,100],[-10,105],[-12,110],[-15,115],[-18,120],[-20,118],[-25,112],[-28,108],[-32,105],[-35,100],[-38,95],[-40,90],[-42,85],[-45,80],[-48,75],[-50,70],[-55,65],[-58,60],[-60,55],[-58,50],[-55,45],[-52,42],[-50,38],[-48,35],[-45,38],[-42,42],[-40,48],[-35,52],[-30,55],[-25,52],[-22,48],[-18,45],[-15,48],[-12,52],[-8,55],[-5,52],[-2,48],[0,45],[3,42],[5,38],[8,35],[10,38],[12,42],[15,48],[20,55],[25,62],[30,68],[35,72],[40,75],[45,78],[50,80],[55,78],[60,75],[65,70],[68,65],[70,60],[72,55]],
// Japan
[[45,142],[42,140],[38,138],[35,136],[32,135],[30,137],[28,140],[30,143],[33,145],[35,143],[38,142],[42,144],[45,143],[45,142]],
// SE Asia islands
[[8,115],[-2,110],[-6,105],[-8,108],[-5,112],[-2,115],[0,118],[2,120],[5,118],[8,115]],
[[0,125],[-5,128],[-8,130],[-6,135],[-3,138],[0,140],[3,138],[5,135],[3,130],[0,125]],
// Australia
[[-12,130],[-15,128],[-20,126],[-25,128],[-30,132],[-35,138],[-38,142],[-40,148],[-38,152],[-35,155],[-30,153],[-25,148],[-20,145],[-18,140],[-15,138],[-12,135],[-10,132],[-12,130]],
// New Zealand
[[-35,172],[-38,170],[-42,172],[-45,174],[-47,172],[-45,168],[-42,170],[-38,172],[-35,172]],
// Antarctica (hint)
[[-65,-170],[-65,-120],[-68,-60],[-70,0],[-72,60],[-70,120],[-65,170],[-75,170],[-78,120],[-80,60],[-80,0],[-78,-60],[-75,-120],[-72,-170],[-65,-170]],
];
const WORLD_MAP_SVG = 'data:image/svg+xml,' + encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-180 -90 360 180" width="2048" height="1024">
<rect x="-180" y="-90" width="360" height="180" fill="#f0f2f5"/>
<!-- Simplified continent outlines as paths -->
<g fill="#e2e6ea" stroke="#c0c5cc" stroke-width="0.3">
<!-- North America -->
<path d="M-168,-20 L-130,-20 L-120,-10 L-100,-10 L-90,0 L-80,0 L-70,15 L-80,30 L-90,30 L-100,25 L-120,30 L-130,25 L-140,15 L-150,10 L-168,5 Z"/>
<!-- South America -->
<path d="M-80,10 L-70,10 L-60,0 L-50,-5 L-40,0 L-35,10 L-40,20 L-50,30 L-55,35 L-60,30 L-70,25 L-80,20 Z"/>
<!-- Europe -->
<path d="M-10,35 L0,30 L10,35 L20,40 L30,45 L35,50 L40,55 L30,60 L20,65 L10,70 L0,72 L-10,68 L-10,55 L-5,45 L-10,40 Z"/>
<!-- Africa -->
<path d="M-5,32 L5,30 L15,25 L25,20 L35,10 L45,5 L50,15 L45,25 L35,30 L30,35 L25,35 L20,40 L10,42 L0,40 L-5,38 Z"/>
<!-- Asia -->
<path d="M30,35 L50,30 L70,25 L90,20 L110,15 L130,10 L140,20 L145,30 L140,40 L130,50 L120,60 L110,65 L100,70 L80,72 L60,75 L50,73 L40,70 L30,68 L20,60 L25,50 L30,45 Z"/>
<!-- Australia -->
<path d="M115,20 L125,15 L140,15 L150,20 L155,30 L145,40 L135,42 L125,38 L118,30 L115,25 Z"/>
<!-- East Asia islands -->
<path d="M130,28 L135,25 L140,28 L142,32 L138,36 L133,35 L130,32 Z"/>
<!-- Southeast Asia islands -->
<path d="M100,20 L105,15 L115,12 L120,15 L118,20 L110,22 L105,25 L100,23 Z"/>
<!-- Greenland -->
<path d="M-50,15 L-40,5 L-30,5 L-25,15 L-30,25 L-40,28 L-50,25 Z"/>
<!-- Antarctica hint -->
<path d="M-170,55 L-120,52 L-60,55 L0,58 L60,55 L120,52 L170,55 L170,65 L120,68 L60,70 L0,72 L-60,70 L-120,68 L-170,65 Z" fill="#e8eaed"/>
</g>
<!-- Grid lines -->
<g stroke="#d5d9de" stroke-width="0.15" stroke-dasharray="2,4">
<line x1="-180" y1="-60" x2="180" y2="-60"/><line x1="-180" y1="-30" x2="180" y2="-30"/>
<line x1="-180" y1="0" x2="180" y2="0"/><line x1="-180" y1="30" x2="180" y2="30"/>
<line x1="-180" y1="60" x2="180" y2="60"/><line x1="-90" y1="-90" x2="-90" y2="90"/>
<line x1="0" y1="-90" x2="0" y2="90"/><line x1="90" y1="-90" x2="90" y2="90"/>
</g>
</svg>`);
// Subnet color palette — one color per /24 prefix
const SUBNET_COLORS = [
'#4361ee','#43aa8b','#f72585','#f4a261','#e76f51','#264653','#2a9d8f',
'#e9c46a','#8ecae6','#219ebc','#023047','#ffb703','#fb8500','#6a4c93',
'#ff6b6b','#4ecdc4','#45b7d1','#96ceb4','#d4a5a5','#a445b2',
'#e17055','#00b894','#0984e3','#6c5ce7','#fd79a8','#00cec9','#e66767',
'#3dc1d3','#e77f67','#778beb','#cf6a87','#63cdda','#ea8685','#596275',
];
function getSubnetColor(ip) {
// Hash the /24 subnet to a color
const parts = ip.split('.');
if (parts.length >= 3) {
const hash = (parseInt(parts[0]) * 31 + parseInt(parts[1]) * 17 + parseInt(parts[2]) * 7) % SUBNET_COLORS.length;
return SUBNET_COLORS[Math.abs(hash)];
}
return SUBNET_COLORS[0];
}
function initMap() {
const container = document.getElementById('saMap');
if (!container) return;
// Ensure container has dimensions
container.style.height = '550px';
container.style.width = '100%';
if (SA.map) {
container.style.height = '550px';
setTimeout(() => { SA.map.invalidateSize(); renderMapData(); }, 200);
SA.map.invalidateSize();
renderMapData();
return;
}
setTimeout(() => {
container.style.height = '550px';
SA.map = L.map('saMap', {
center: [20, 0], zoom: 2, minZoom: 2, maxZoom: 12,
worldCopyJump: true, preferCanvas: true,
attributionControl: false, zoomControl: true,
});
// Static world map image — no tile requests
L.imageOverlay(WORLD_MAP_SVG, [[-90, -180], [90, 180]], {opacity: 1}).addTo(SA.map);
// Wait for container to be visible (step switching from display:none)
function tryInit(attempts) {
if (container.offsetHeight > 0 || attempts <= 0) {
SA.map = L.map('saMap', {
center: [20, 0], zoom: 2, minZoom: 2, maxZoom: 14,
worldCopyJump: true, preferCanvas: true,
attributionControl: false, zoomControl: true,
});
SA.map.setMaxBounds([[-90, -180], [90, 180]]);
SA.map.on('zoomend moveend', () => renderMapData());
setTimeout(() => SA.map.invalidateSize(), 300);
renderMapData();
}, 250);
// Draw continents
try {
CONTINENTS.forEach(ring => {
L.polygon(ring, {
color: '#b0b8c4', weight: 0.8, opacity: 0.7,
fillColor: '#e8ecf0', fillOpacity: 1,
interactive: false,
}).addTo(SA.map);
});
// Grid lines
for (let lat = -60; lat <= 60; lat += 30) {
L.polyline([[lat, -180], [lat, 180]], {
color: '#dde1e6', weight: 0.5, interactive: false,
}).addTo(SA.map);
}
for (let lon = -150; lon <= 150; lon += 30) {
L.polyline([[-90, lon], [90, lon]], {
color: '#dde1e6', weight: 0.5, interactive: false,
}).addTo(SA.map);
}
} catch(e) { console.error('Map draw error:', e); }
SA.map.setMaxBounds([[-85, -180], [85, 180]]);
SA.map.on('zoomend moveend', () => renderMapData());
SA.map.invalidateSize();
renderMapData();
} else {
setTimeout(() => tryInit(attempts - 1), 150);
}
}
setTimeout(() => tryInit(10), 100);
}
// ── Grid-based marker clustering ──────────────────────────
// ── Render all IP nodes (colored by subnet) ───────────────
function renderMapData() {
if (!SA.map) return;
if (SA.pointLayer) SA.map.removeLayer(SA.pointLayer);
@@ -1241,86 +1285,41 @@ function renderMapData() {
const bounds = SA.map.getBounds();
const zoom = SA.map.getZoom();
const markers = [];
const centerMarkers = [];
// Grid cell size in degrees (adapts to zoom level)
const cellSize = Math.max(0.05, 60 / Math.pow(2, zoom));
const grid = {}; // key: "latBin_lonBin" → {nodes, clusterIds}
// Per-subnet color cache
const subnetColorCache = {};
nodes.forEach(n => {
if (n.lat == null || n.lon == null) return;
if (!bounds.contains([n.lat, n.lon])) return;
// Skip hidden clusters
if (SA.hiddenClusters.has(n.cluster_id)) return;
const latBin = Math.floor(n.lat / cellSize);
const lonBin = Math.floor(n.lon / cellSize);
const key = `${latBin}_${lonBin}`;
if (!grid[key]) grid[key] = {nodes: [], clusterIds: new Set(), lat: 0, lon: 0};
grid[key].nodes.push(n);
grid[key].clusterIds.add(n.cluster_id);
grid[key].lat += n.lat;
grid[key].lon += n.lon;
// Color by /24 subnet
if (!subnetColorCache[n.ip]) subnetColorCache[n.ip] = getSubnetColor(n.ip);
const color = subnetColorCache[n.ip];
const circle = L.circleMarker([n.lat, n.lon], {
radius: 4, fillColor: color, color: '#fff', weight: 0.5,
opacity: 0.85, fillOpacity: 0.75,
interactive: true, bubblingMouseEvents: false,
});
circle.bindTooltip(buildNodeTooltip(n), {sticky: true, direction: 'top'});
circle.on('click', function(e) {
L.DomEvent.stopPropagation(e);
loadNodeDetail(n.ip);
});
markers.push(circle);
});
const markers = [];
const centerMarkers = [];
// Community center markers (larger dots for multi-node communities)
const colors = generateColors(20);
const clusterColorMap = {};
Object.values(clusters).sort((a, b) => b.size - a.size).forEach((c, i) => {
clusterColorMap[c.label] = c.size <= 1 ? '#b0b0b0' : colors[i % colors.length];
clusterColorMap[c.label] = colors[i % colors.length];
});
Object.entries(grid).forEach(([key, cell]) => {
const count = cell.nodes.length;
const avgLat = cell.lat / count;
const avgLon = cell.lon / count;
if (count === 1) {
// Single node in cell — render directly
const n = cell.nodes[0];
const color = clusterColorMap[n.cluster_id] || '#4361ee';
const circle = L.circleMarker([n.lat, n.lon], {
radius: 4, fillColor: color, color: '#fff', weight: 0.5,
opacity: 0.85, fillOpacity: 0.7,
});
circle.bindTooltip(buildNodeTooltip(n), {sticky: true});
circle.on('click', () => loadNodeDetail(n.ip));
markers.push(circle);
} else if (count <= 10) {
// Few nodes — render all with slight offset
cell.nodes.forEach((n, i) => {
const color = clusterColorMap[n.cluster_id] || '#4361ee';
const circle = L.circleMarker([n.lat, n.lon], {
radius: 4, fillColor: color, color: '#fff', weight: 0.5,
opacity: 0.75, fillOpacity: 0.6,
});
circle.bindTooltip(buildNodeTooltip(n), {sticky: true});
circle.on('click', () => loadNodeDetail(n.ip));
markers.push(circle);
});
} else {
// Many nodes — show cluster marker
const dominantCluster = [...cell.clusterIds].sort((a, b) => {
const ca = cell.nodes.filter(n => n.cluster_id === a).length;
const cb = cell.nodes.filter(n => n.cluster_id === b).length;
return cb - ca;
})[0];
const color = clusterColorMap[dominantCluster] || '#4361ee';
const icon = L.divIcon({
html: `<div style="background:${color};width:${Math.min(28, 14 + count/3)}px;height:${Math.min(28, 14 + count/3)}px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;color:#fff;font-size:${Math.min(11, 8 + count/10)}px;font-weight:700;">${count}</div>`,
className: '', iconSize: [28, 28], iconAnchor: [14, 14],
});
const marker = L.marker([avgLat, avgLon], {icon});
marker.bindTooltip(`<b>${count} 个节点</b><br>${cell.clusterIds.size} 个社区<br>点击放大查看`, {direction: 'top'});
marker.on('click', () => {
SA.map.setView([avgLat, avgLon], Math.min(zoom + 3, 12));
});
markers.push(marker);
}
});
// Cluster center markers (only for multi-node clusters)
Object.entries(clusters).forEach(([cid, cinfo]) => {
const cidInt = parseInt(cid);
if (cinfo.size <= 1 || SA.hiddenClusters.has(cidInt)) return;
@@ -1328,48 +1327,62 @@ function renderMapData() {
if (!bounds.contains([cinfo.center_lat, cinfo.center_lon])) return;
const color = clusterColorMap[cidInt] || '#4361ee';
const icon = L.divIcon({
html: `<div style="background:${color};width:20px;height:20px;border-radius:50%;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;color:#fff;font-size:9px;font-weight:700;">${cidInt}</div>`,
className: '', iconSize: [20, 20], iconAnchor: [10, 10],
const circle = L.circleMarker([cinfo.center_lat, cinfo.center_lon], {
radius: 8, fillColor: color, color: '#fff', weight: 2,
opacity: 0.95, fillOpacity: 0.8,
interactive: true, bubblingMouseEvents: false,
});
const marker = L.marker([cinfo.center_lat, cinfo.center_lon], {icon});
marker.bindTooltip(`<b>社区 ${cidInt} 中心</b><br>${cinfo.size} IP · ${cinfo.subnet || ''}`, {direction: 'top'});
marker.on('click', () => loadClusterDetail(cidInt));
centerMarkers.push(marker);
circle.bindTooltip(
`<div class="sa-point-tooltip"><b>社区 ${cidInt}</b><br>${cinfo.size} IP · ${cinfo.subnet || ''}<br>点击查看详情</div>`,
{direction: 'top'}
);
circle.on('click', function(e) {
L.DomEvent.stopPropagation(e);
loadClusterDetail(cidInt);
});
centerMarkers.push(circle);
});
SA.pointLayer = L.layerGroup(markers).addTo(SA.map);
SA.centerLayer = L.layerGroup(centerMarkers).addTo(SA.map);
renderLegend(colors);
renderLegend();
}
function buildNodeTooltip(n) {
const serverLabel = n.is_server ? ' 🖥' : '';
const parts = n.ip ? n.ip.split('.') : [];
const subnet = parts.length >= 3 ? parts[0] + '.' + parts[1] + '.' + parts[2] + '.0/24' : '';
return `<div class="sa-point-tooltip">
<b>${escapeHtml(n.ip)}</b>${serverLabel}<br>
${n.city ? `<span>${escapeHtml(n.city)}</span><br>` : ''}
${n.isp ? `<span style="color:#888;">${escapeHtml(n.isp)}</span><br>` : ''}
<span style="color:#4361ee;">点击查看详情</span></div>`;
${subnet ? '<span style="color:#888;">子网: ' + subnet + '</span><br>' : ''}
${n.city ? '<span>' + escapeHtml(n.city) + '</span>, ' : ''}${escapeHtml(n.country || '')}<br>
${n.isp ? '<span style="color:#666;">' + escapeHtml(n.isp) + '</span><br>' : ''}
<span style="color:#4361ee;">🖱 点击查看详情</span></div>`;
}
function renderLegend(colors) {
function renderLegend() {
const el = document.getElementById('saLegendItems');
const clusters = SA.clusterMap || {};
const sorted = Object.values(clusters)
.sort((a, b) => b.size - a.size);
const sorted = Object.values(clusters).sort((a, b) => b.size - a.size);
if (!sorted.length) { el.innerHTML = ''; return; }
const html = [];
sorted.forEach((c, idx) => {
const color = c.size <= 1 ? '#b0b0b0' : colors[idx % colors.length];
const colors = generateColors(sorted.length);
// Show top 20 communities + subnet info
const top = sorted.slice(0, 20);
const html = top.map((c, idx) => {
const color = colors[idx % colors.length];
const hidden = SA.hiddenClusters.has(c.label);
const label = c.size <= 1 ? '独立节点' : '社区 ' + c.label;
html.push(`<div class="sa-legend-item${hidden ? ' hidden' : ''}"
return `<div class="sa-legend-item${hidden ? ' hidden' : ''}"
onclick="toggleCluster(${c.label})">
<span class="color-dot" style="background:${color}"></span>
<span>${label}</span>
<span style="color:#999;font-size:0.8rem;">${c.size} IP</span>
</div>`);
<span style="color:#999;font-size:0.75rem;">${c.size} IP · ${c.subnet || ''}</span>
</div>`;
});
if (sorted.length > 20) {
html.push(`<div style="font-size:0.75rem;color:#999;padding:0.3rem;">... 还有 ${sorted.length - 20} 个社区</div>`);
}
el.innerHTML = html.join('');
}