693 lines
22 KiB
Python
693 lines
22 KiB
Python
"""
|
|
Script pour générer une page HTML interactive avec les données des villages corses embarquées.
|
|
Usage: python generate_interactive_map.py villages_corse.csv
|
|
"""
|
|
|
|
import csv
|
|
import json
|
|
import sys
|
|
|
|
def parse_coordinates(point_geo_str):
|
|
"""Parse la colonne Point_Geo"""
|
|
try:
|
|
parts = point_geo_str.split(',')
|
|
lat = float(parts[0].strip())
|
|
lon = float(parts[1].strip())
|
|
return lat, lon
|
|
except:
|
|
return None, None
|
|
|
|
def load_villages_from_csv(csv_file):
|
|
"""Charge les villages depuis le CSV"""
|
|
villages = []
|
|
|
|
with open(csv_file, 'r', encoding='utf-8') as f:
|
|
reader = csv.reader(f, delimiter=';')
|
|
next(reader) # Skip header
|
|
|
|
for row in reader:
|
|
if len(row) >= 18:
|
|
lat, lon = parse_coordinates(row[17])
|
|
if lat and lon:
|
|
villages.append({
|
|
'name': row[0],
|
|
'nameCorse': row[1],
|
|
'lat': lat,
|
|
'lon': lon,
|
|
'dept': row[9],
|
|
'altitude': float(row[15]) if row[15] else 0
|
|
})
|
|
|
|
return villages
|
|
|
|
def generate_html(villages, output_file='knn_interactive_full.html'):
|
|
"""Génère le fichier HTML avec les données embarquées"""
|
|
|
|
villages_json = json.dumps(villages, ensure_ascii=False)
|
|
|
|
html_content = f'''<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Classification k-NN Interactive - Corse</title>
|
|
|
|
<!-- Leaflet CSS -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
|
|
<style>
|
|
* {{
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}}
|
|
|
|
body {{
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
padding: 20px;
|
|
}}
|
|
|
|
.container {{
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
background: white;
|
|
border-radius: 15px;
|
|
overflow: hidden;
|
|
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
|
}}
|
|
|
|
.header {{
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
padding: 30px;
|
|
text-align: center;
|
|
}}
|
|
|
|
.header h1 {{
|
|
font-size: 2.5em;
|
|
margin-bottom: 10px;
|
|
}}
|
|
|
|
.header p {{
|
|
font-size: 1.2em;
|
|
opacity: 0.9;
|
|
}}
|
|
|
|
.main-content {{
|
|
display: grid;
|
|
grid-template-columns: 350px 1fr;
|
|
gap: 0;
|
|
}}
|
|
|
|
.sidebar {{
|
|
background: #f5f5f5;
|
|
padding: 30px;
|
|
border-right: 2px solid #ddd;
|
|
max-height: 900px;
|
|
overflow-y: auto;
|
|
}}
|
|
|
|
.controls {{
|
|
background: white;
|
|
padding: 20px;
|
|
border-radius: 10px;
|
|
margin-bottom: 20px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
}}
|
|
|
|
.controls h3 {{
|
|
margin-bottom: 15px;
|
|
color: #333;
|
|
font-size: 1.2em;
|
|
}}
|
|
|
|
.control-group {{
|
|
margin-bottom: 20px;
|
|
}}
|
|
|
|
.control-group label {{
|
|
display: block;
|
|
font-weight: bold;
|
|
color: #555;
|
|
margin-bottom: 8px;
|
|
}}
|
|
|
|
.slider-container {{
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}}
|
|
|
|
input[type="range"] {{
|
|
flex: 1;
|
|
height: 8px;
|
|
border-radius: 5px;
|
|
background: #ddd;
|
|
outline: none;
|
|
}}
|
|
|
|
input[type="range"]::-webkit-slider-thumb {{
|
|
appearance: none;
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 50%;
|
|
background: #667eea;
|
|
cursor: pointer;
|
|
}}
|
|
|
|
input[type="range"]::-moz-range-thumb {{
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 50%;
|
|
background: #667eea;
|
|
cursor: pointer;
|
|
border: none;
|
|
}}
|
|
|
|
.k-value {{
|
|
background: #667eea;
|
|
color: white;
|
|
padding: 5px 15px;
|
|
border-radius: 20px;
|
|
font-weight: bold;
|
|
min-width: 40px;
|
|
text-align: center;
|
|
}}
|
|
|
|
.result-box {{
|
|
background: white;
|
|
padding: 20px;
|
|
border-radius: 10px;
|
|
margin-bottom: 20px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
}}
|
|
|
|
.result-box h3 {{
|
|
margin-bottom: 15px;
|
|
color: #333;
|
|
}}
|
|
|
|
.prediction {{
|
|
font-size: 1.3em;
|
|
font-weight: bold;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
margin-bottom: 15px;
|
|
}}
|
|
|
|
.prediction.corse-sud {{
|
|
background: #ffebee;
|
|
color: #c62828;
|
|
}}
|
|
|
|
.prediction.haute-corse {{
|
|
background: #e3f2fd;
|
|
color: #1565c0;
|
|
}}
|
|
|
|
.prediction.no-result {{
|
|
background: #f5f5f5;
|
|
color: #666;
|
|
}}
|
|
|
|
.coords {{
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
text-align: center;
|
|
margin-bottom: 10px;
|
|
}}
|
|
|
|
.votes {{
|
|
display: flex;
|
|
justify-content: space-around;
|
|
margin-bottom: 15px;
|
|
}}
|
|
|
|
.vote-item {{
|
|
text-align: center;
|
|
}}
|
|
|
|
.vote-count {{
|
|
font-size: 2em;
|
|
font-weight: bold;
|
|
}}
|
|
|
|
.vote-count.red {{
|
|
color: #c62828;
|
|
}}
|
|
|
|
.vote-count.blue {{
|
|
color: #1565c0;
|
|
}}
|
|
|
|
.neighbors-list {{
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
background: #fafafa;
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
}}
|
|
|
|
.neighbor-item {{
|
|
padding: 10px;
|
|
margin-bottom: 8px;
|
|
background: white;
|
|
border-radius: 5px;
|
|
border-left: 4px solid #ddd;
|
|
}}
|
|
|
|
.neighbor-item.dept-2a {{
|
|
border-left-color: #c62828;
|
|
}}
|
|
|
|
.neighbor-item.dept-2b {{
|
|
border-left-color: #1565c0;
|
|
}}
|
|
|
|
.neighbor-name {{
|
|
font-weight: bold;
|
|
margin-bottom: 3px;
|
|
}}
|
|
|
|
.neighbor-corse {{
|
|
font-style: italic;
|
|
color: #666;
|
|
font-size: 0.9em;
|
|
}}
|
|
|
|
.neighbor-distance {{
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
}}
|
|
|
|
.instructions {{
|
|
background: #fff3cd;
|
|
border-left: 4px solid #ffc107;
|
|
padding: 15px;
|
|
border-radius: 5px;
|
|
margin-bottom: 20px;
|
|
}}
|
|
|
|
.instructions h4 {{
|
|
margin-bottom: 10px;
|
|
color: #856404;
|
|
}}
|
|
|
|
.instructions ul {{
|
|
margin-left: 20px;
|
|
color: #856404;
|
|
}}
|
|
|
|
.instructions li {{
|
|
margin-bottom: 5px;
|
|
}}
|
|
|
|
.legend {{
|
|
background: white;
|
|
padding: 15px;
|
|
border-radius: 10px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
}}
|
|
|
|
.legend h4 {{
|
|
margin-bottom: 10px;
|
|
color: #333;
|
|
}}
|
|
|
|
.legend-item {{
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 8px;
|
|
}}
|
|
|
|
.legend-circle {{
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 50%;
|
|
border: 2px solid #333;
|
|
}}
|
|
|
|
.legend-circle.red {{
|
|
background: #e74c3c;
|
|
}}
|
|
|
|
.legend-circle.blue {{
|
|
background: #3498db;
|
|
}}
|
|
|
|
.legend-circle.marker {{
|
|
background: #95a5a6;
|
|
border: 3px solid #000;
|
|
}}
|
|
|
|
#map {{
|
|
height: 900px;
|
|
cursor: crosshair;
|
|
}}
|
|
|
|
.leaflet-popup-content {{
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
}}
|
|
|
|
button {{
|
|
padding: 10px 20px;
|
|
border: none;
|
|
border-radius: 5px;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
width: 100%;
|
|
margin-bottom: 10px;
|
|
}}
|
|
|
|
button:hover {{
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
|
}}
|
|
|
|
.btn-reset {{
|
|
background: #f44336;
|
|
color: white;
|
|
}}
|
|
|
|
.btn-random {{
|
|
background: #4CAF50;
|
|
color: white;
|
|
}}
|
|
|
|
.stats {{
|
|
background: #e3f2fd;
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
font-size: 0.9em;
|
|
text-align: center;
|
|
margin-top: 15px;
|
|
color: #1565c0;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>🗺️ Classification k-NN Interactive</h1>
|
|
<p>Haute-Corse ou Corse du Sud ?</p>
|
|
<div class="stats">
|
|
<strong>{len(villages)}</strong> villages chargés
|
|
</div>
|
|
</div>
|
|
|
|
<div class="main-content">
|
|
<div class="sidebar">
|
|
<div class="instructions">
|
|
<h4>📋 Instructions</h4>
|
|
<ul>
|
|
<li><strong>Cliquez</strong> n'importe où sur la carte</li>
|
|
<li>Ajustez la valeur de <strong>k</strong> avec le curseur</li>
|
|
<li>Observez les <strong>k plus proches villages</strong></li>
|
|
<li>La <strong>classification</strong> se fait par vote majoritaire</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<h3>⚙️ Paramètres</h3>
|
|
<div class="control-group">
|
|
<label>Nombre de voisins (k) :</label>
|
|
<div class="slider-container">
|
|
<input type="range" id="kSlider" min="1" max="21" value="5" step="2">
|
|
<span class="k-value" id="kValue">5</span>
|
|
</div>
|
|
</div>
|
|
<button class="btn-random" onclick="placeRandomPoint()">🎲 Point Aléatoire</button>
|
|
<button class="btn-reset" onclick="resetClassification()">🔄 Réinitialiser</button>
|
|
</div>
|
|
|
|
<div class="result-box" id="resultBox">
|
|
<h3>🎯 Résultat</h3>
|
|
<div class="prediction no-result" id="prediction">
|
|
Cliquez sur la carte
|
|
</div>
|
|
<div class="coords" id="coords"></div>
|
|
<div id="votesContainer" style="display: none;">
|
|
<div class="votes">
|
|
<div class="vote-item">
|
|
<div class="vote-count red" id="votes2A">0</div>
|
|
<div>Corse du Sud</div>
|
|
</div>
|
|
<div class="vote-item">
|
|
<div class="vote-count blue" id="votes2B">0</div>
|
|
<div>Haute-Corse</div>
|
|
</div>
|
|
</div>
|
|
<h4>🏘️ Plus proches voisins :</h4>
|
|
<div class="neighbors-list" id="neighborsList"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="legend">
|
|
<h4>Légende</h4>
|
|
<div class="legend-item">
|
|
<div class="legend-circle red"></div>
|
|
<span>Corse du Sud (2A)</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-circle blue"></div>
|
|
<span>Haute-Corse (2B)</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-circle marker"></div>
|
|
<span>Point à classifier</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="map"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Leaflet JS -->
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
|
|
<script>
|
|
// Données des villages (embarquées depuis le CSV)
|
|
const villages = {villages_json};
|
|
|
|
let map, testMarker, neighborMarkers = [], neighborLines = [];
|
|
let currentK = 5;
|
|
|
|
// Initialiser la carte
|
|
function initMap() {{
|
|
map = L.map('map').setView([42.15, 9.05], 9);
|
|
|
|
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{
|
|
attribution: '© OpenStreetMap contributors'
|
|
}}).addTo(map);
|
|
|
|
// Ajouter tous les villages
|
|
villages.forEach(village => {{
|
|
const color = village.dept === '2A' ? '#e74c3c' : '#3498db';
|
|
L.circleMarker([village.lat, village.lon], {{
|
|
radius: 3,
|
|
fillColor: color,
|
|
color: '#333',
|
|
weight: 1,
|
|
opacity: 1,
|
|
fillOpacity: 0.5
|
|
}}).bindPopup(`<b>${{village.name}}</b><br>${{village.nameCorse}}<br>${{village.dept}}`)
|
|
.addTo(map);
|
|
}});
|
|
|
|
// Ajouter le gestionnaire de clic
|
|
map.on('click', onMapClick);
|
|
}}
|
|
|
|
// Calcul de distance Haversine
|
|
function haversineDistance(lat1, lon1, lat2, lon2) {{
|
|
const R = 6371; // Rayon de la Terre en km
|
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
|
Math.sin(dLon/2) * Math.sin(dLon/2);
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
|
return R * c;
|
|
}}
|
|
|
|
// Classification k-NN
|
|
function knnClassify(lat, lon, k) {{
|
|
// Calculer distances
|
|
const distances = villages.map(village => ({{
|
|
...village,
|
|
distance: haversineDistance(lat, lon, village.lat, village.lon)
|
|
}}));
|
|
|
|
// Trier par distance
|
|
distances.sort((a, b) => a.distance - b.distance);
|
|
|
|
// Prendre les k plus proches
|
|
const neighbors = distances.slice(0, k);
|
|
|
|
// Voter
|
|
const votes = {{ '2A': 0, '2B': 0 }};
|
|
neighbors.forEach(n => votes[n.dept]++);
|
|
|
|
const prediction = votes['2A'] > votes['2B'] ? '2A' : '2B';
|
|
|
|
return {{ prediction, neighbors, votes }};
|
|
}}
|
|
|
|
// Gestionnaire de clic sur la carte
|
|
function onMapClick(e) {{
|
|
const lat = e.latlng.lat;
|
|
const lon = e.latlng.lng;
|
|
|
|
classifyPoint(lat, lon);
|
|
}}
|
|
|
|
// Classifier un point
|
|
function classifyPoint(lat, lon) {{
|
|
// Supprimer les anciens marqueurs
|
|
if (testMarker) map.removeLayer(testMarker);
|
|
neighborMarkers.forEach(m => map.removeLayer(m));
|
|
neighborLines.forEach(l => map.removeLayer(l));
|
|
neighborMarkers = [];
|
|
neighborLines = [];
|
|
|
|
// Classifier
|
|
const result = knnClassify(lat, lon, currentK);
|
|
|
|
// Ajouter le marqueur du point test
|
|
testMarker = L.circleMarker([lat, lon], {{
|
|
radius: 12,
|
|
fillColor: '#95a5a6',
|
|
color: '#000',
|
|
weight: 3,
|
|
opacity: 1,
|
|
fillOpacity: 0.8
|
|
}}).addTo(map);
|
|
|
|
// Ajouter les marqueurs et lignes des voisins
|
|
result.neighbors.forEach(neighbor => {{
|
|
const nColor = neighbor.dept === '2A' ? '#e74c3c' : '#3498db';
|
|
|
|
// Ligne
|
|
const line = L.polyline(
|
|
[[lat, lon], [neighbor.lat, neighbor.lon]],
|
|
{{color: nColor, weight: 2, opacity: 0.5}}
|
|
).addTo(map);
|
|
neighborLines.push(line);
|
|
|
|
// Marqueur
|
|
const marker = L.circleMarker([neighbor.lat, neighbor.lon], {{
|
|
radius: 8,
|
|
fillColor: nColor,
|
|
color: '#333',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 0.8
|
|
}}).bindPopup(`<b>${{neighbor.name}}</b><br>${{neighbor.nameCorse}}<br>${{neighbor.dept}}<br>Distance: ${{neighbor.distance.toFixed(2)}} km`)
|
|
.addTo(map);
|
|
neighborMarkers.push(marker);
|
|
}});
|
|
|
|
// Afficher les résultats
|
|
displayResults(lat, lon, result);
|
|
}}
|
|
|
|
// Afficher les résultats
|
|
function displayResults(lat, lon, result) {{
|
|
const predictionDiv = document.getElementById('prediction');
|
|
const coordsDiv = document.getElementById('coords');
|
|
const votesContainer = document.getElementById('votesContainer');
|
|
const votes2A = document.getElementById('votes2A');
|
|
const votes2B = document.getElementById('votes2B');
|
|
const neighborsList = document.getElementById('neighborsList');
|
|
|
|
// Coordonnées
|
|
coordsDiv.textContent = `(${{lat.toFixed(4)}}, ${{lon.toFixed(4)}})`;
|
|
|
|
// Prédiction
|
|
const deptName = result.prediction === '2A' ? 'Corse du Sud (2A)' : 'Haute-Corse (2B)';
|
|
const cssClass = result.prediction === '2A' ? 'corse-sud' : 'haute-corse';
|
|
predictionDiv.textContent = deptName;
|
|
predictionDiv.className = 'prediction ' + cssClass;
|
|
|
|
// Votes
|
|
votes2A.textContent = result.votes['2A'];
|
|
votes2B.textContent = result.votes['2B'];
|
|
votesContainer.style.display = 'block';
|
|
|
|
// Liste des voisins
|
|
neighborsList.innerHTML = '';
|
|
result.neighbors.forEach(neighbor => {{
|
|
const div = document.createElement('div');
|
|
div.className = `neighbor-item dept-${{neighbor.dept.toLowerCase()}}`;
|
|
div.innerHTML = `
|
|
<div class="neighbor-name">${{neighbor.name}} (${{neighbor.dept}})</div>
|
|
<div class="neighbor-corse">${{neighbor.nameCorse}}</div>
|
|
<div class="neighbor-distance">Distance: ${{neighbor.distance.toFixed(2)}} km</div>
|
|
`;
|
|
neighborsList.appendChild(div);
|
|
}});
|
|
}}
|
|
|
|
// Point aléatoire
|
|
function placeRandomPoint() {{
|
|
const lat = 41.3 + Math.random() * (43.0 - 41.3);
|
|
const lon = 8.5 + Math.random() * (9.6 - 8.5);
|
|
classifyPoint(lat, lon);
|
|
map.setView([lat, lon], 10);
|
|
}}
|
|
|
|
// Réinitialiser
|
|
function resetClassification() {{
|
|
if (testMarker) map.removeLayer(testMarker);
|
|
neighborMarkers.forEach(m => map.removeLayer(m));
|
|
neighborLines.forEach(l => map.removeLayer(l));
|
|
neighborMarkers = [];
|
|
neighborLines = [];
|
|
testMarker = null;
|
|
|
|
document.getElementById('prediction').textContent = 'Cliquez sur la carte';
|
|
document.getElementById('prediction').className = 'prediction no-result';
|
|
document.getElementById('coords').textContent = '';
|
|
document.getElementById('votesContainer').style.display = 'none';
|
|
|
|
map.setView([42.15, 9.05], 9);
|
|
}}
|
|
|
|
// Gestionnaire du slider k
|
|
document.getElementById('kSlider').addEventListener('input', function(e) {{
|
|
currentK = parseInt(e.target.value);
|
|
document.getElementById('kValue').textContent = currentK;
|
|
|
|
// Reclassifier si un point existe
|
|
if (testMarker) {{
|
|
const latlng = testMarker.getLatLng();
|
|
classifyPoint(latlng.lat, latlng.lng);
|
|
}}
|
|
}});
|
|
|
|
// Initialisation
|
|
initMap();
|
|
</script>
|
|
</body>
|
|
</html>'''
|
|
|
|
with open(output_file, 'w', encoding='utf-8') as f:
|
|
f.write(html_content)
|
|
|
|
print(f"✅ Fichier HTML généré : {output_file}")
|
|
print(f"📊 {len(villages)} villages inclus")
|
|
|
|
if __name__ == '__main__':
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python generate_interactive_map.py villages_corse.csv")
|
|
sys.exit(1)
|
|
|
|
csv_file = sys.argv[1]
|
|
villages = load_villages_from_csv(csv_file)
|
|
generate_html(villages)
|