const SAINT_MALO = [48.649, -2.025]; const CATEGORY_ICONS = { 'bouรฉe': '๐ŸŸข', 'pointe': '๐Ÿ“', 'plage': '๐Ÿ–', 'รฎle': '๐Ÿ', 'port': 'โš“', 'fort': '๐Ÿฐ', 'chenal': '๐Ÿ”ต', 'anse': '๐ŸŒŠ', 'rocher': 'โฌ›', 'autre': '๐Ÿ“Œ' }; let map, mode = 'quiz', points = []; const editMarkers = new Map(); // Quiz state let quizQueue = [], quizCurrent = null, quizScore = 0, quizTotal = 0; let quizAnswered = false; let userMarker = null, correctMarker = null, feedbackLine = null; let pendingLatLng = null; async function init() { const status = await fetch('/api/status').then(r => r.json()); map = L.map('map', { center: SAINT_MALO, zoom: 12 }); const src = status.hasMbtiles ? `MBTiles local (${status.name || 'SHOM'})` : 'SHOM en ligne'; document.getElementById('tiles-status').textContent = `๐Ÿ—บ ${src}`; L.tileLayer('/tiles/{z}/{x}/{y}', { minZoom: 6, maxZoom: 17, attribution: 'ยฉ SHOM' }).addTo(map); map.on('click', onMapClick); await loadPoints(); setMode('quiz'); } // โ”€โ”€ Mode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function setMode(newMode) { mode = newMode; document.getElementById('btn-quiz').classList.toggle('active', mode === 'quiz'); document.getElementById('btn-edit').classList.toggle('active', mode === 'edit'); document.getElementById('quiz-panel').style.display = mode === 'quiz' ? 'flex' : 'none'; document.getElementById('edit-panel').style.display = mode === 'edit' ? 'flex' : 'none'; document.getElementById('map').classList.toggle('edit-cursor', mode === 'edit'); clearFeedback(); renderEditMarkers(); if (mode === 'quiz') startQuiz(); } // โ”€โ”€ Map click โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function onMapClick(e) { if (mode === 'edit') { pendingLatLng = e.latlng; openModal(null); } else if (mode === 'quiz' && quizCurrent && !quizAnswered) { submitAnswer(e.latlng); } } // โ”€โ”€ Points API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function loadPoints() { points = await fetch('/api/points').then(r => r.json()); document.getElementById('points-count').textContent = points.length; renderPointsList(); renderEditMarkers(); } async function saveNewPoint(data) { const pt = await fetch('/api/points', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }).then(r => r.json()); return pt; } async function updatePoint(id, data) { await fetch(`/api/points/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); } async function deletePoint(id) { if (!confirm('Supprimer ce point ?')) return; await fetch(`/api/points/${id}`, { method: 'DELETE' }); await loadPoints(); } // โ”€โ”€ Edit markers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function makeIcon(category) { return L.divIcon({ html: `
${CATEGORY_ICONS[category] || '๐Ÿ“Œ'}
`, className: '', iconSize: [24, 24], iconAnchor: [12, 12] }); } function renderEditMarkers() { editMarkers.forEach(m => map.removeLayer(m)); editMarkers.clear(); if (mode !== 'edit') return; points.forEach(p => { const m = L.marker([p.lat, p.lng], { icon: makeIcon(p.category) }) .bindTooltip(p.name, { direction: 'top' }) .on('click', e => { L.DomEvent.stopPropagation(e); openModal(p); }) .addTo(map); editMarkers.set(p.id, m); }); } // โ”€โ”€ Points list (edit panel) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function renderPointsList() { const list = document.getElementById('points-list'); if (points.length === 0) { list.innerHTML = '
Aucun point encore.
'; return; } list.innerHTML = points.map(p => `
${CATEGORY_ICONS[p.category] || '๐Ÿ“Œ'}
${escHtml(p.name)}
${p.category}
`).join(''); } function getPoint(id) { return points.find(p => p.id === id); } function flyTo(id) { const p = getPoint(id); if (p) map.flyTo([p.lat, p.lng], 14); } function escHtml(s) { return s.replace(/&/g, '&').replace(//g, '>'); } // โ”€โ”€ Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function openModal(point) { document.getElementById('modal-title').textContent = point ? 'Modifier le point' : 'Ajouter un point'; document.getElementById('point-id').value = point?.id || ''; document.getElementById('point-lat').value = point ? point.lat : (pendingLatLng?.lat || ''); document.getElementById('point-lng').value = point ? point.lng : (pendingLatLng?.lng || ''); document.getElementById('point-name').value = point?.name || ''; document.getElementById('point-category').value = point?.category || 'bouรฉe'; document.getElementById('point-hint').value = point?.hint || ''; document.getElementById('modal-overlay').style.display = 'flex'; setTimeout(() => document.getElementById('point-name').focus(), 50); } function closeModal() { document.getElementById('modal-overlay').style.display = 'none'; pendingLatLng = null; } function handleOverlayClick(e) { if (e.target === document.getElementById('modal-overlay')) closeModal(); } document.getElementById('point-form').addEventListener('submit', async e => { e.preventDefault(); const id = document.getElementById('point-id').value; const data = { name: document.getElementById('point-name').value.trim(), category: document.getElementById('point-category').value, hint: document.getElementById('point-hint').value.trim(), lat: parseFloat(document.getElementById('point-lat').value), lng: parseFloat(document.getElementById('point-lng').value) }; if (id) await updatePoint(id, data); else await saveNewPoint(data); closeModal(); await loadPoints(); }); // โ”€โ”€ Quiz โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function startQuiz() { clearFeedback(); if (points.length === 0) { show('quiz-empty'); hide('quiz-question'); hide('quiz-end'); return; } quizQueue = shuffle([...points]); quizScore = 0; quizTotal = 0; hide('quiz-empty'); show('quiz-question'); hide('quiz-feedback'); nextQuestion(); } function nextQuestion() { clearFeedback(); quizAnswered = false; if (quizQueue.length === 0) { quizQueue = shuffle([...points]); } quizCurrent = quizQueue.shift(); quizTotal++; document.getElementById('quiz-name').textContent = quizCurrent.name; document.getElementById('quiz-category').textContent = quizCurrent.category; document.getElementById('quiz-hint').textContent = quizCurrent.hint || ''; document.getElementById('quiz-instruction').style.display = 'block'; hide('quiz-feedback'); updateScoreDisplay(); } function submitAnswer(latlng) { if (!quizCurrent || quizAnswered) return; quizAnswered = true; const correct = L.latLng(quizCurrent.lat, quizCurrent.lng); const distM = latlng.distanceTo(correct); const distNm = distM / 1852; // User click marker userMarker = L.circleMarker(latlng, { radius: 8, color: '#f39c12', fillColor: '#f39c12', fillOpacity: 0.85, weight: 2 }).addTo(map); // Correct location marker correctMarker = L.circleMarker(correct, { radius: 8, color: '#2ecc71', fillColor: '#2ecc71', fillOpacity: 0.85, weight: 2 }).bindTooltip(quizCurrent.name, { permanent: true, direction: 'top', offset: [0, -10] }).addTo(map); // Dashed line feedbackLine = L.polyline([latlng, correct], { color: '#fff', weight: 1.5, dashArray: '5 5', opacity: 0.4 }).addTo(map); // Scoring let pts, label, color; if (distNm < 0.1) { pts = 100; label = 'โญ Excellent !'; color = 'var(--green)'; } else if (distNm < 0.5) { pts = 80; label = '๐Ÿ‘ Trรจs bien'; color = 'var(--green)'; } else if (distNm < 1.0) { pts = 60; label = '๐Ÿ‘Œ Bien'; color = 'var(--yellow)'; } else if (distNm < 3.0) { pts = 30; label = '๐Ÿค” Passable'; color = 'var(--orange)'; } else { pts = 0; label = 'โŒ ร€ revoir'; color = 'var(--red)'; } quizScore += pts; updateScoreDisplay(); const distStr = distNm < 0.1 ? `${Math.round(distM)} m` : `${distNm.toFixed(2)} Nm`; document.getElementById('feedback-distance').textContent = `Distance : ${distStr}`; document.getElementById('feedback-label').textContent = label; document.getElementById('feedback-label').style.color = color; document.getElementById('quiz-instruction').style.display = 'none'; show('quiz-feedback'); // Fit both points in view map.fitBounds(L.latLngBounds([latlng, correct]).pad(0.4), { maxZoom: 14 }); } function clearFeedback() { if (userMarker) { map.removeLayer(userMarker); userMarker = null; } if (correctMarker) { map.removeLayer(correctMarker); correctMarker = null; } if (feedbackLine) { map.removeLayer(feedbackLine); feedbackLine = null; } } function updateScoreDisplay() { document.getElementById('score-value').textContent = quizScore; const remaining = quizQueue.length; document.getElementById('quiz-progress').textContent = `${quizTotal} / ${quizTotal + remaining} questions`; } // โ”€โ”€ Utils โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function show(id) { document.getElementById(id).style.display = ''; } function hide(id) { document.getElementById(id).style.display = 'none'; } function shuffle(arr) { return arr.sort(() => Math.random() - 0.5); } document.addEventListener('keydown', e => { if (e.code === 'Space' || e.code === 'Enter') { if (mode === 'quiz' && quizAnswered) { e.preventDefault(); nextQuestion(); } } }); init();