visnetmap 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- visnetmap-0.1.0.dist-info/METADATA +18 -0
- visnetmap-0.1.0.dist-info/RECORD +10 -0
- visnetmap-0.1.0.dist-info/WHEEL +5 -0
- visnetmap-0.1.0.dist-info/licenses/LICENSE +21 -0
- visnetmap-0.1.0.dist-info/top_level.txt +1 -0
- vistNETmap/__init__.py +0 -0
- vistNETmap/base_modes/__init__.py +1 -0
- vistNETmap/base_modes/netmap_base.py +660 -0
- vistNETmap/base_modes/network_visual_save_base.py +1014 -0
- vistNETmap/visNM.py +257 -0
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from geopy.geocoders import Nominatim
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
def latLong(place):
|
|
8
|
+
user_agent = random.randint(10000, 99999)
|
|
9
|
+
geolocator = Nominatim(user_agent=str(user_agent))
|
|
10
|
+
location = geolocator.geocode(place)
|
|
11
|
+
latitude = location.latitude
|
|
12
|
+
longitude = location.longitude
|
|
13
|
+
full_address = location.address
|
|
14
|
+
return latitude, longitude, full_address
|
|
15
|
+
|
|
16
|
+
def netmap(cities_data, connections_data, title="Network Map", maximum_nodes=100, output_html_file="network_map.html", default_size=5):
|
|
17
|
+
"""
|
|
18
|
+
cities_data (list): {
|
|
19
|
+
"node": "New York", "lat": 40.7128, "lon": -74.0060, "size": 15,
|
|
20
|
+
"color": "red", "shape": "circle", "node_hover": "The Big Apple, USA"
|
|
21
|
+
}, # shape: circle, triangle, square, star
|
|
22
|
+
connections_data(list): {
|
|
23
|
+
"node1": "New York", "node2": "Paris", "color": "darkblue", "width": 2,
|
|
24
|
+
"style": "dashed", "arrow": False, "curve": True,
|
|
25
|
+
"edge_hover": any text e.g. "Transatlantic flight"
|
|
26
|
+
"default_size": node size 5
|
|
27
|
+
},
|
|
28
|
+
maximum_nodes: maximum nodes to display initially
|
|
29
|
+
"""
|
|
30
|
+
df_nodes = pd.DataFrame(cities_data)
|
|
31
|
+
if not df_nodes['lat'].apply(lambda x: isinstance(x, (int, float)) and not isinstance(x, bool)).all():
|
|
32
|
+
raise ValueError("latitude contains non-numeric values.")
|
|
33
|
+
if not df_nodes['lon'].apply(lambda x: isinstance(x, (int, float)) and not isinstance(x, bool)).all():
|
|
34
|
+
raise ValueError("longitude contains non-numeric values.")
|
|
35
|
+
if not df_nodes['size'].apply(lambda x: isinstance(x, (int, float)) and not isinstance(x, bool)).all():
|
|
36
|
+
raise ValueError("node size contains non-numeric values.")
|
|
37
|
+
|
|
38
|
+
df_edges = pd.DataFrame(connections_data)
|
|
39
|
+
if not df_edges['width'].apply(lambda x: isinstance(x, (int, float)) and not isinstance(x, bool)).all():
|
|
40
|
+
raise ValueError("edge width contains non-numeric values.")
|
|
41
|
+
|
|
42
|
+
# Define default values
|
|
43
|
+
defaults_city = {
|
|
44
|
+
"lat": lambda city: latLong(city["node"])[0],
|
|
45
|
+
"lon": lambda city: latLong(city["node"])[1],
|
|
46
|
+
"size": default_size,
|
|
47
|
+
"color": "gray",
|
|
48
|
+
"shape": "circle",
|
|
49
|
+
"node_hover": lambda city: city["node"]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for city in cities_data:
|
|
53
|
+
for key, default_value in defaults_city.items():
|
|
54
|
+
if key not in city:
|
|
55
|
+
city[key] = default_value(city) if callable(default_value) else default_value
|
|
56
|
+
|
|
57
|
+
defaults_connection = {
|
|
58
|
+
"color": "gray",
|
|
59
|
+
"width": 1,
|
|
60
|
+
"style": "solid",
|
|
61
|
+
"arrow": False,
|
|
62
|
+
"curve": False,
|
|
63
|
+
"edge_hover": lambda connection: (connection['node1'] + ' to ' + connection['node2'])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for connection in connections_data:
|
|
67
|
+
for key, default_value in defaults_connection.items():
|
|
68
|
+
if key not in connection:
|
|
69
|
+
connection[key] = default_value(connection) if callable(default_value) else default_value
|
|
70
|
+
|
|
71
|
+
# Pre-filter based on maximum_nodes for initial load
|
|
72
|
+
sorted_cities = sorted(cities_data, key=lambda x: x['size'], reverse=True)
|
|
73
|
+
selected_cities = sorted_cities[:min(maximum_nodes, len(cities_data))]
|
|
74
|
+
selected_city_nodes = {city['node'] for city in selected_cities}
|
|
75
|
+
filtered_connections = [conn for conn in connections_data if conn['node1'] in selected_city_nodes and conn['node2'] in selected_city_nodes]
|
|
76
|
+
|
|
77
|
+
cities_json = json.dumps(selected_cities, indent=2)
|
|
78
|
+
connections_json = json.dumps(filtered_connections, indent=2)
|
|
79
|
+
all_cities_json = json.dumps(cities_data, indent=2)
|
|
80
|
+
all_connections_json = json.dumps(connections_data, indent=2)
|
|
81
|
+
|
|
82
|
+
total_nodes = len(cities_data)
|
|
83
|
+
total_edges = len(connections_data)
|
|
84
|
+
effective_max_nodes = min(maximum_nodes, len(selected_cities))
|
|
85
|
+
|
|
86
|
+
# Extract unique colors and hover texts from all cities_data
|
|
87
|
+
unique_colors = sorted(set(city['color'] for city in cities_data))
|
|
88
|
+
unique_hovers = sorted(set(city['node_hover'] for city in cities_data))
|
|
89
|
+
|
|
90
|
+
html_content = f"""
|
|
91
|
+
<!DOCTYPE html>
|
|
92
|
+
<html>
|
|
93
|
+
<head>
|
|
94
|
+
<title>Network Map</title>
|
|
95
|
+
<meta charset="utf-8" />
|
|
96
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
97
|
+
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
98
|
+
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
|
|
99
|
+
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
|
|
100
|
+
<style>
|
|
101
|
+
html, body, #map {{
|
|
102
|
+
height: 100%;
|
|
103
|
+
margin: 0;
|
|
104
|
+
}}
|
|
105
|
+
.arrow-label {{
|
|
106
|
+
background: white;
|
|
107
|
+
padding: 2px 6px;
|
|
108
|
+
border-radius: 4px;
|
|
109
|
+
font-size: 12px;
|
|
110
|
+
box-shadow: 0 0 4px rgba(0,0,0,0.3);
|
|
111
|
+
pointer-events: none;
|
|
112
|
+
white-space: nowrap;
|
|
113
|
+
}}
|
|
114
|
+
.leaflet-control-title {{
|
|
115
|
+
background: rgba(255, 255, 255, 0.8);
|
|
116
|
+
padding: 10px 20px;
|
|
117
|
+
border-radius: 5px;
|
|
118
|
+
font-size: 18px;
|
|
119
|
+
font-weight: bold;
|
|
120
|
+
text-align: center;
|
|
121
|
+
box-shadow: 0 0 8px rgba(0,0,0,0.3);
|
|
122
|
+
cursor: move;
|
|
123
|
+
position: absolute;
|
|
124
|
+
top: 10px;
|
|
125
|
+
left: 50%;
|
|
126
|
+
transform: translateX(-50%);
|
|
127
|
+
z-index: 1000;
|
|
128
|
+
user-select: none;
|
|
129
|
+
}}
|
|
130
|
+
.slider-container {{
|
|
131
|
+
position: absolute;
|
|
132
|
+
bottom: 10px;
|
|
133
|
+
left: 10px;
|
|
134
|
+
z-index: 1000;
|
|
135
|
+
background: rgba(255, 255, 255, 0.8);
|
|
136
|
+
padding: 10px;
|
|
137
|
+
border-radius: 5px;
|
|
138
|
+
box-shadow: 0 0 8px rgba(0,0,0,0.3);
|
|
139
|
+
display: none; /* Initially hidden */
|
|
140
|
+
}}
|
|
141
|
+
.slider-label {{
|
|
142
|
+
font-size: 14px;
|
|
143
|
+
margin-bottom: 5px;
|
|
144
|
+
}}
|
|
145
|
+
.dimmed {{
|
|
146
|
+
opacity: 0.3;
|
|
147
|
+
}}
|
|
148
|
+
.dropdown-container {{
|
|
149
|
+
margin-top: 10px;
|
|
150
|
+
}}
|
|
151
|
+
select, input {{
|
|
152
|
+
width: 100%;
|
|
153
|
+
padding: 5px;
|
|
154
|
+
font-size: 14px;
|
|
155
|
+
border-radius: 4px;
|
|
156
|
+
}}
|
|
157
|
+
.toggle-button {{
|
|
158
|
+
position: absolute;
|
|
159
|
+
top: 10px; /* Moved to top to avoid covering controls */
|
|
160
|
+
right: 10px; /* Positioned to the right */
|
|
161
|
+
z-index: 1001;
|
|
162
|
+
background: rgba(255, 255, 255, 0.8);
|
|
163
|
+
padding: 8px 12px;
|
|
164
|
+
border-radius: 5px;
|
|
165
|
+
font-size: 14px;
|
|
166
|
+
cursor: pointer;
|
|
167
|
+
box-shadow: 0 0 8px rgba(0,0,0,0.3);
|
|
168
|
+
border: none;
|
|
169
|
+
}}
|
|
170
|
+
.toggle-button:hover {{
|
|
171
|
+
background: rgba(255, 255, 255, 1);
|
|
172
|
+
}}
|
|
173
|
+
</style>
|
|
174
|
+
</head>
|
|
175
|
+
<body>
|
|
176
|
+
<div id="map"></div>
|
|
177
|
+
<div id="map-title" class="leaflet-control-title">{title}</div>
|
|
178
|
+
<button id="toggle-controls" class="toggle-button">Show Controls</button>
|
|
179
|
+
<div id="controls" class="slider-container">
|
|
180
|
+
<div class="slider-label">Max Nodes: <span id="node-count">{effective_max_nodes}</span></div>
|
|
181
|
+
<input type="range" id="node-slider" min="1" max="{total_nodes}" value="{effective_max_nodes}" step="1">
|
|
182
|
+
<div class="slider-label">Edges: <span id="edge-count">0 / {total_edges}</span></div>
|
|
183
|
+
<div class="dropdown-container">
|
|
184
|
+
<div class="slider-label">Filter by Color:</div>
|
|
185
|
+
<select id="color-filter" multiple>
|
|
186
|
+
<option value="all">All Colors</option>
|
|
187
|
+
{"".join(f'<option value="{color}">{color}</option>' for color in unique_colors)}
|
|
188
|
+
</select>
|
|
189
|
+
</div>
|
|
190
|
+
<div class="dropdown-container">
|
|
191
|
+
<div class="slider-label">Filter by Hover:</div>
|
|
192
|
+
<select id="hover-filter" multiple>
|
|
193
|
+
<option value="all">All Hovers</option>
|
|
194
|
+
{"".join(f'<option value="{hover}">{hover}</option>' for hover in unique_hovers)}
|
|
195
|
+
</select>
|
|
196
|
+
</div>
|
|
197
|
+
<div class="dropdown-container">
|
|
198
|
+
<div class="slider-label">Lat Range (-90 to 90):</div>
|
|
199
|
+
<input type="number" id="lat-min" min="-90" max="90" value="-90" step="0.1">
|
|
200
|
+
<input type="number" id="lat-max" min="-90" max="90" value="90" step="0.1">
|
|
201
|
+
</div>
|
|
202
|
+
<div class="dropdown-container">
|
|
203
|
+
<div class="slider-label">Lon Range (-180 to 180):</div>
|
|
204
|
+
<input type="number" id="lon-min" min="-180" max="180" value="-180" step="0.1">
|
|
205
|
+
<input type="number" id="lon-max" min="-180" max="180" value="180" step="0.1">
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
210
|
+
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
|
|
211
|
+
<script src="https://rawcdn.githack.com/makinacorpus/Leaflet.TextPath/master/leaflet.textpath.js"></script>
|
|
212
|
+
|
|
213
|
+
<script>
|
|
214
|
+
const cities = {cities_json};
|
|
215
|
+
const connections = {connections_json};
|
|
216
|
+
const all_cities = {all_cities_json};
|
|
217
|
+
const all_connections = {all_connections_json};
|
|
218
|
+
const total_nodes = {total_nodes};
|
|
219
|
+
const total_edges = {total_edges};
|
|
220
|
+
let selectedNode = null;
|
|
221
|
+
let selectedColor = ['all'];
|
|
222
|
+
let selectedHover = ['all'];
|
|
223
|
+
let latMin = -90;
|
|
224
|
+
let latMax = 90;
|
|
225
|
+
let lonMin = -180;
|
|
226
|
+
let lonMax = 180;
|
|
227
|
+
|
|
228
|
+
const map = L.map('map').setView([20, 0], 2);
|
|
229
|
+
L.tileLayer('https://{{s}}.basemaps.cartocdn.com/light_all/{{z}}/{{x}}/{{y}}.png', {{
|
|
230
|
+
maxZoom: 19,
|
|
231
|
+
attribution: '© CartoDB'
|
|
232
|
+
}}).addTo(map);
|
|
233
|
+
|
|
234
|
+
const titleDiv = document.getElementById('map-title');
|
|
235
|
+
const draggable = new L.Draggable(titleDiv);
|
|
236
|
+
draggable.enable();
|
|
237
|
+
|
|
238
|
+
map.getContainer().style.position = 'relative';
|
|
239
|
+
|
|
240
|
+
// Initialize marker cluster group and edges layer
|
|
241
|
+
const markers = L.markerClusterGroup({{
|
|
242
|
+
maxClusterRadius: 50,
|
|
243
|
+
disableClusteringAtZoom: 10,
|
|
244
|
+
spiderfyOnMaxZoom: true
|
|
245
|
+
}});
|
|
246
|
+
const edges = L.layerGroup();
|
|
247
|
+
map.addLayer(markers);
|
|
248
|
+
map.addLayer(edges);
|
|
249
|
+
|
|
250
|
+
// Function to create a marker for a city
|
|
251
|
+
function createMarker(city) {{
|
|
252
|
+
let iconHtml = '';
|
|
253
|
+
const size = city.size * 2;
|
|
254
|
+
|
|
255
|
+
if (city.shape === "circle") {{
|
|
256
|
+
iconHtml = `<div style="width:${{size}}px;height:${{size}}px;background:${{city.color}};border-radius:50%;opacity:0.8;"></div>`;
|
|
257
|
+
}} else if (city.shape === "square") {{
|
|
258
|
+
iconHtml = `<div style="width:${{size}}px;height:${{size}}px;background:${{city.color}};opacity:0.8;"></div>`;
|
|
259
|
+
}} else if (city.shape === "star") {{
|
|
260
|
+
iconHtml = `
|
|
261
|
+
<svg width="${{size}}" height="${{size}}" viewBox="0 0 100 100">
|
|
262
|
+
<polygon points="50,5 61,35 95,35 67,57 76,91 50,70 24,91 33,57 5,35 39,35"
|
|
263
|
+
fill="${{city.color}}" opacity="0.8" />
|
|
264
|
+
</svg>`;
|
|
265
|
+
}}
|
|
266
|
+
|
|
267
|
+
const icon = L.divIcon({{
|
|
268
|
+
html: iconHtml,
|
|
269
|
+
className: "",
|
|
270
|
+
iconSize: [size, size]
|
|
271
|
+
}});
|
|
272
|
+
|
|
273
|
+
const marker = L.marker([city.lat, city.lon], {{
|
|
274
|
+
icon: icon
|
|
275
|
+
}});
|
|
276
|
+
|
|
277
|
+
if (city.node_hover) {{
|
|
278
|
+
marker.bindTooltip(city.node_hover, {{
|
|
279
|
+
direction: 'top',
|
|
280
|
+
permanent: false,
|
|
281
|
+
opacity: 0.9
|
|
282
|
+
}});
|
|
283
|
+
}}
|
|
284
|
+
|
|
285
|
+
marker.bindPopup(city.node);
|
|
286
|
+
|
|
287
|
+
marker.on('click', () => {{
|
|
288
|
+
selectedNode = city.node;
|
|
289
|
+
debouncedUpdateNodes(parseInt(document.getElementById('node-slider').value));
|
|
290
|
+
}});
|
|
291
|
+
|
|
292
|
+
return marker;
|
|
293
|
+
}}
|
|
294
|
+
|
|
295
|
+
function generateBezierPoints(p0, p1, p2, numPoints) {{
|
|
296
|
+
const points = [p0];
|
|
297
|
+
for (let t = 1 / (numPoints - 1); t < 1; t += 1 / (numPoints - 1)) {{
|
|
298
|
+
const u = 1 - t;
|
|
299
|
+
const tt = t * t;
|
|
300
|
+
const uu = u * u;
|
|
301
|
+
const lat = uu * p0[0] + 2 * u * t * p1[0] + tt * p2[0];
|
|
302
|
+
const lon = uu * p0[1] + 2 * u * t * p1[1] + tt * p2[1];
|
|
303
|
+
points.push([lat, lon]);
|
|
304
|
+
}}
|
|
305
|
+
points.push(p2);
|
|
306
|
+
return points;
|
|
307
|
+
}}
|
|
308
|
+
|
|
309
|
+
// Debounce function to limit update frequency
|
|
310
|
+
function debounce(func, wait) {{
|
|
311
|
+
let timeout;
|
|
312
|
+
return function executedFunction(...args) {{
|
|
313
|
+
const later = () => {{
|
|
314
|
+
clearTimeout(timeout);
|
|
315
|
+
func(...args);
|
|
316
|
+
}};
|
|
317
|
+
clearTimeout(timeout);
|
|
318
|
+
timeout = setTimeout(later, wait);
|
|
319
|
+
}};
|
|
320
|
+
}}
|
|
321
|
+
|
|
322
|
+
function updateNodes(maxNodes) {{
|
|
323
|
+
document.getElementById('node-count').textContent = maxNodes + ' / ' + total_nodes;
|
|
324
|
+
let filteredCities = all_cities;
|
|
325
|
+
|
|
326
|
+
// Apply color filter
|
|
327
|
+
if (selectedColor[0] !== 'all' && selectedColor.length > 0) {{
|
|
328
|
+
filteredCities = filteredCities.filter(city => selectedColor.includes(city.color));
|
|
329
|
+
}}
|
|
330
|
+
|
|
331
|
+
// Apply hover filter
|
|
332
|
+
if (selectedHover[0] !== 'all' && selectedHover.length > 0) {{
|
|
333
|
+
filteredCities = filteredCities.filter(city => selectedHover.includes(city.node_hover));
|
|
334
|
+
}}
|
|
335
|
+
|
|
336
|
+
// Apply lat/lon range filter
|
|
337
|
+
filteredCities = filteredCities.filter(city =>
|
|
338
|
+
city.lat >= latMin && city.lat <= latMax &&
|
|
339
|
+
city.lon >= lonMin && city.lon <= lonMax
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
// Apply max nodes limit
|
|
343
|
+
const sortedCities = filteredCities.sort((a, b) => b.size - a.size).slice(0, maxNodes);
|
|
344
|
+
const selectedCitynodes = new Set(sortedCities.map(city => city.node));
|
|
345
|
+
|
|
346
|
+
// Get visible bounds
|
|
347
|
+
const bounds = map.getBounds();
|
|
348
|
+
const visibleCities = sortedCities.filter(city =>
|
|
349
|
+
bounds.contains([city.lat, city.lon])
|
|
350
|
+
);
|
|
351
|
+
const visibleCitynodes = new Set(visibleCities.map(city => city.node));
|
|
352
|
+
|
|
353
|
+
// Clear existing markers, edges, and hover labels
|
|
354
|
+
markers.clearLayers();
|
|
355
|
+
edges.clearLayers();
|
|
356
|
+
map.eachLayer(layer => {{
|
|
357
|
+
if (layer instanceof L.Marker && layer.options.icon.options.className === 'arrow-label') {{
|
|
358
|
+
map.removeLayer(layer);
|
|
359
|
+
}}
|
|
360
|
+
}});
|
|
361
|
+
|
|
362
|
+
// Add markers for visible cities
|
|
363
|
+
visibleCities.forEach(city => {{
|
|
364
|
+
const marker = createMarker(city);
|
|
365
|
+
if (selectedNode && city.node !== selectedNode && !all_connections.some(link =>
|
|
366
|
+
(link.node1 === selectedNode && link.node2 === city.node) ||
|
|
367
|
+
(link.node2 === selectedNode && link.node1 === city.node))) {{
|
|
368
|
+
marker.setOpacity(0.3);
|
|
369
|
+
}} else {{
|
|
370
|
+
marker.setOpacity(1);
|
|
371
|
+
}}
|
|
372
|
+
markers.addLayer(marker);
|
|
373
|
+
}});
|
|
374
|
+
|
|
375
|
+
// Filter connections to include edges between visible nodes or connected to selectedNode
|
|
376
|
+
const filteredConnections = all_connections.filter(link =>
|
|
377
|
+
(visibleCitynodes.has(link.node1) && visibleCitynodes.has(link.node2)) ||
|
|
378
|
+
(selectedNode && (link.node1 === selectedNode || link.node2 === selectedNode))
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// Update node count to reflect visible nodes out of filtered total
|
|
382
|
+
document.getElementById('node-count').textContent = visibleCities.length + ' / ' + sortedCities.length;
|
|
383
|
+
|
|
384
|
+
// Update edge count display
|
|
385
|
+
document.getElementById('edge-count').textContent = filteredConnections.length + ' / ' + total_edges;
|
|
386
|
+
|
|
387
|
+
// Detect bidirectional edges
|
|
388
|
+
const bidirectionalEdges = new Set();
|
|
389
|
+
filteredConnections.forEach((link, index) => {{
|
|
390
|
+
const reverseLink = filteredConnections.find((l, i) => i !== index && l.node1 === link.node2 && l.node2 === link.node1);
|
|
391
|
+
if (reverseLink) {{
|
|
392
|
+
bidirectionalEdges.add(`${{link.node1}}-${{link.node2}}`);
|
|
393
|
+
bidirectionalEdges.add(`${{link.node2}}-${{link.node1}}`);
|
|
394
|
+
}}
|
|
395
|
+
}});
|
|
396
|
+
|
|
397
|
+
filteredConnections.forEach(link => {{
|
|
398
|
+
const fromCity = all_cities.find(c => c.node === link.node1);
|
|
399
|
+
const toCity = all_cities.find(c => c.node === link.node2);
|
|
400
|
+
// Skip if either node is missing
|
|
401
|
+
if (!fromCity || !toCity) return;
|
|
402
|
+
|
|
403
|
+
const from = [fromCity.lat, fromCity.lon];
|
|
404
|
+
const to = [toCity.lat, toCity.lon];
|
|
405
|
+
|
|
406
|
+
let latlngs = [from, to];
|
|
407
|
+
const edgeKey = `${{link.node1}}-${{link.node2}}`;
|
|
408
|
+
const isBidirectional = bidirectionalEdges.has(edgeKey);
|
|
409
|
+
const shouldCurve = link.curve || isBidirectional;
|
|
410
|
+
|
|
411
|
+
if (shouldCurve) {{
|
|
412
|
+
const midLat = (from[0] + to[0]) / 2;
|
|
413
|
+
const midLon = (from[1] + to[1]) / 2;
|
|
414
|
+
const latDiff = to[0] - from[0];
|
|
415
|
+
const lonDiff = to[1] - from[1];
|
|
416
|
+
const length = Math.sqrt(latDiff * latDiff + lonDiff * lonDiff);
|
|
417
|
+
const baseOffsetMagnitude = Math.min(length * 0.1, 5);
|
|
418
|
+
const perpLat = -lonDiff / length * baseOffsetMagnitude;
|
|
419
|
+
const perpLon = latDiff / length * baseOffsetMagnitude;
|
|
420
|
+
|
|
421
|
+
let controlLat, controlLon;
|
|
422
|
+
if (isBidirectional) {{
|
|
423
|
+
const isReverse = link.node1 > link.node2;
|
|
424
|
+
const bidirectionalOffset = baseOffsetMagnitude * 2;
|
|
425
|
+
controlLat = midLat + (isReverse ? -perpLat * 2 : perpLat * 2) + (isReverse ? bidirectionalOffset : -bidirectionalOffset);
|
|
426
|
+
controlLon = midLon + (isReverse ? -perpLon * 2 : perpLon * 2) + (isReverse ? bidirectionalOffset : -bidirectionalOffset);
|
|
427
|
+
}} else {{
|
|
428
|
+
controlLat = midLat + perpLat;
|
|
429
|
+
controlLon = midLon + perpLon;
|
|
430
|
+
}}
|
|
431
|
+
|
|
432
|
+
latlngs = generateBezierPoints(from, [controlLat, controlLon], to, 50);
|
|
433
|
+
}}
|
|
434
|
+
|
|
435
|
+
const lineStyle = {{
|
|
436
|
+
color: link.color,
|
|
437
|
+
weight: link.width,
|
|
438
|
+
dashArray:
|
|
439
|
+
link.style === "dashed" ? "5, 10" :
|
|
440
|
+
link.style === "dotted" ? "2, 6" : null,
|
|
441
|
+
opacity: selectedNode && !(link.node1 === selectedNode || link.node2 === selectedNode) ? 0.3 : 1
|
|
442
|
+
}};
|
|
443
|
+
|
|
444
|
+
const polyline = L.polyline(latlngs, {{
|
|
445
|
+
...lineStyle
|
|
446
|
+
}}).addTo(edges);
|
|
447
|
+
|
|
448
|
+
if (link.arrow) {{
|
|
449
|
+
const arrowSize = Math.max(10, link.width * 4);
|
|
450
|
+
polyline.setText('▶', {{
|
|
451
|
+
repeat: false,
|
|
452
|
+
center: true,
|
|
453
|
+
offset: 0,
|
|
454
|
+
attributes: {{
|
|
455
|
+
fill: link.color,
|
|
456
|
+
"font-size": `${{arrowSize}}`,
|
|
457
|
+
"font-weight": "bold",
|
|
458
|
+
"dy": `${{arrowSize / 3}}`,
|
|
459
|
+
opacity: selectedNode && !(link.node1 === selectedNode || link.node2 === selectedNode) ? 0.3 : 1
|
|
460
|
+
}}
|
|
461
|
+
}});
|
|
462
|
+
}}
|
|
463
|
+
|
|
464
|
+
const hoverLabel = L.marker([0, 0], {{
|
|
465
|
+
icon: L.divIcon({{
|
|
466
|
+
className: 'arrow-label',
|
|
467
|
+
html: link.edge_hover || `${{link.node1}} → ${{link.node2}}`,
|
|
468
|
+
iconSize: null
|
|
469
|
+
}}),
|
|
470
|
+
interactive: false
|
|
471
|
+
}});
|
|
472
|
+
|
|
473
|
+
const hoverLine = L.polyline(latlngs, {{
|
|
474
|
+
color: '#0000',
|
|
475
|
+
weight: Math.max(link.width + 10, 15),
|
|
476
|
+
opacity: 0,
|
|
477
|
+
interactive: true
|
|
478
|
+
}}).addTo(edges);
|
|
479
|
+
|
|
480
|
+
hoverLine.on("mouseover", (e) => {{
|
|
481
|
+
hoverLabel.setLatLng(e.latlng);
|
|
482
|
+
map.addLayer(hoverLabel);
|
|
483
|
+
}});
|
|
484
|
+
hoverLine.on("mouseout", () => {{
|
|
485
|
+
if (map.hasLayer(hoverLabel)) {{
|
|
486
|
+
map.removeLayer(hoverLabel);
|
|
487
|
+
}}
|
|
488
|
+
}});
|
|
489
|
+
hoverLine.on("click", () => {{
|
|
490
|
+
if (map.hasLayer(hoverLabel)) {{
|
|
491
|
+
map.removeLayer(hoverLabel);
|
|
492
|
+
}}
|
|
493
|
+
}});
|
|
494
|
+
}});
|
|
495
|
+
}}
|
|
496
|
+
|
|
497
|
+
// Debounced update function
|
|
498
|
+
const debouncedUpdateNodes = debounce(updateNodes, 300);
|
|
499
|
+
|
|
500
|
+
// Toggle controls visibility
|
|
501
|
+
const toggleButton = document.getElementById('toggle-controls');
|
|
502
|
+
const controlsDiv = document.getElementById('controls');
|
|
503
|
+
toggleButton.addEventListener('click', () => {{
|
|
504
|
+
if (controlsDiv.style.display === 'none') {{
|
|
505
|
+
controlsDiv.style.display = 'block';
|
|
506
|
+
toggleButton.textContent = 'Hide Controls';
|
|
507
|
+
}} else {{
|
|
508
|
+
controlsDiv.style.display = 'none';
|
|
509
|
+
toggleButton.textContent = 'Show Controls';
|
|
510
|
+
}}
|
|
511
|
+
}});
|
|
512
|
+
|
|
513
|
+
// Update selected values for multiple selections
|
|
514
|
+
function updateSelectedValues(selectElement, selectedArray) {{
|
|
515
|
+
const options = Array.from(selectElement.selectedOptions);
|
|
516
|
+
selectedArray.length = 0; // Clear the array
|
|
517
|
+
options.forEach(option => selectedArray.push(option.value));
|
|
518
|
+
if (selectedArray.length === 0) selectedArray.push('all'); // Default to 'all' if nothing selected
|
|
519
|
+
}}
|
|
520
|
+
|
|
521
|
+
// Event listeners for multiple selections
|
|
522
|
+
document.getElementById('color-filter').addEventListener('change', (e) => {{
|
|
523
|
+
updateSelectedValues(e.target, selectedColor);
|
|
524
|
+
debouncedUpdateNodes(parseInt(document.getElementById('node-slider').value));
|
|
525
|
+
}});
|
|
526
|
+
|
|
527
|
+
document.getElementById('hover-filter').addEventListener('change', (e) => {{
|
|
528
|
+
updateSelectedValues(e.target, selectedHover);
|
|
529
|
+
debouncedUpdateNodes(parseInt(document.getElementById('node-slider').value));
|
|
530
|
+
}});
|
|
531
|
+
|
|
532
|
+
document.getElementById('lat-min').addEventListener('input', (e) => {{
|
|
533
|
+
latMin = parseFloat(e.target.value);
|
|
534
|
+
debouncedUpdateNodes(parseInt(document.getElementById('node-slider').value));
|
|
535
|
+
}});
|
|
536
|
+
|
|
537
|
+
document.getElementById('lat-max').addEventListener('input', (e) => {{
|
|
538
|
+
latMax = parseFloat(e.target.value);
|
|
539
|
+
debouncedUpdateNodes(parseInt(document.getElementById('node-slider').value));
|
|
540
|
+
}});
|
|
541
|
+
|
|
542
|
+
document.getElementById('lon-min').addEventListener('input', (e) => {{
|
|
543
|
+
lonMin = parseFloat(e.target.value);
|
|
544
|
+
debouncedUpdateNodes(parseInt(document.getElementById('node-slider').value));
|
|
545
|
+
}});
|
|
546
|
+
|
|
547
|
+
document.getElementById('lon-max').addEventListener('input', (e) => {{
|
|
548
|
+
lonMax = parseFloat(e.target.value);
|
|
549
|
+
debouncedUpdateNodes(parseInt(document.getElementById('node-slider').value));
|
|
550
|
+
}});
|
|
551
|
+
|
|
552
|
+
document.getElementById('node-slider').addEventListener('input', (e) => {{
|
|
553
|
+
debouncedUpdateNodes(parseInt(e.target.value));
|
|
554
|
+
}});
|
|
555
|
+
|
|
556
|
+
map.on('click', (e) => {{
|
|
557
|
+
if (!e.originalEvent.target.closest('.leaflet-marker-icon')) {{
|
|
558
|
+
selectedNode = null;
|
|
559
|
+
debouncedUpdateNodes(parseInt(document.getElementById('node-slider').value));
|
|
560
|
+
}}
|
|
561
|
+
}});
|
|
562
|
+
|
|
563
|
+
map.on('moveend zoomend', () => {{
|
|
564
|
+
debouncedUpdateNodes(parseInt(document.getElementById('node-slider').value));
|
|
565
|
+
}});
|
|
566
|
+
|
|
567
|
+
// Initial update
|
|
568
|
+
debouncedUpdateNodes({effective_max_nodes});
|
|
569
|
+
</script>
|
|
570
|
+
</body>
|
|
571
|
+
</html>
|
|
572
|
+
"""
|
|
573
|
+
|
|
574
|
+
with open(output_html_file, "w", encoding="utf-8") as f:
|
|
575
|
+
f.write(html_content)
|
|
576
|
+
print(f"Generated {output_html_file} successfully!")
|
|
577
|
+
import webbrowser
|
|
578
|
+
webbrowser.open(output_html_file)
|
|
579
|
+
|
|
580
|
+
if __name__ == "__main__":
|
|
581
|
+
cities = [
|
|
582
|
+
{"node": "rovaniemi,finland"},
|
|
583
|
+
{
|
|
584
|
+
'node': 'eventti.net',
|
|
585
|
+
'lat': 60.1497055,
|
|
586
|
+
'lon': 24.7376876,
|
|
587
|
+
'size': 10,
|
|
588
|
+
'color': 'blue',
|
|
589
|
+
'shape': 'circle',
|
|
590
|
+
'node_hover': 'eventti.net'
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
"node": "New York", "lat": 40.7128, "lon": -74.0060, "size": 15,
|
|
594
|
+
"color": "red", "shape": "circle", "node_hover": "The Big Apple, USA"
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
"node": "London", "lat": 51.5074, "lon": -0.1278, "size": 12,
|
|
598
|
+
"color": "blue", "shape": "star", "node_hover": "Capital of the UK"
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
"node": "Tokyo", "lat": 35.6762, "lon": 139.6503, "size": 10,
|
|
602
|
+
"color": "green", "shape": "square", "node_hover": "Capital of Japan"
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
"node": "Sydney", "lat": -33.8688, "lon": 151.2093, "size": 8,
|
|
606
|
+
"color": "purple", "shape": "circle", "node_hover": "Australia’s coastal gem"
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
"node": "Paris", "lat": 48.8566, "lon": 2.3522, "size": 11,
|
|
610
|
+
"color": "orange", "shape": "star", "node_hover": "City of Love"
|
|
611
|
+
},
|
|
612
|
+
]
|
|
613
|
+
|
|
614
|
+
connections = [
|
|
615
|
+
{
|
|
616
|
+
"node1": "rovaniemi,finland", "node2": "eventti.net"
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
"node1": "New York", "node2": "London", "color": "black", "width": 2,
|
|
620
|
+
"style": "solid", "arrow": True, "curve": False,
|
|
621
|
+
"edge_hover": "Flight from NY to London"
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
"node1": "Tokyo", "node2": "London", "color": "black", "width": 2,
|
|
625
|
+
"style": "solid", "arrow": True, "curve": False,
|
|
626
|
+
"edge_hover": "Flight tokyo to London"
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
"node1": "London", "node2": "Tokyo", "color": "blue", "width": 3,
|
|
630
|
+
"style": "solid", "arrow": True, "curve": False,
|
|
631
|
+
"edge_hover": "From London to Tokyo"
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
"node1": "Tokyo", "node2": "rovaniemi,finland", "color": "red", "width": 10,
|
|
635
|
+
"style": "dashed", "arrow": True, "curve": True,
|
|
636
|
+
"edge_hover": "From Tokyo to Rovaniemi"
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
"node1": "Tokyo", "node2": "Sydney", "color": "blue", "width": 1,
|
|
640
|
+
"style": "solid", "arrow": True, "curve": False,
|
|
641
|
+
"edge_hover": "Trade route Tokyo ↔ Sydney"
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
"node1": "Sydney", "node2": "New York", "color": "green", "width": 12,
|
|
645
|
+
"style": "solid", "arrow": True, "curve": True,
|
|
646
|
+
"edge_hover": "Tourist route Sydney–NY"
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
"node1": "London", "node2": "Paris", "color": "brown", "width": 4,
|
|
650
|
+
"style": "solid", "arrow": True, "curve": False,
|
|
651
|
+
"edge_hover": "Eurostar connection"
|
|
652
|
+
},
|
|
653
|
+
{
|
|
654
|
+
"node1": "New York", "node2": "Paris", "color": "darkblue", "width": 2,
|
|
655
|
+
"style": "dashed", "arrow": False, "curve": False,
|
|
656
|
+
"edge_hover": "Transatlantic flight"
|
|
657
|
+
},
|
|
658
|
+
]
|
|
659
|
+
|
|
660
|
+
netmap(cities, connections, title="net map", output_html_file='simple.html', maximum_nodes=2)
|