460 lines
24 KiB
Python
Executable File
460 lines
24 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import pandas as pd
|
|
import folium
|
|
from folium import plugins
|
|
from folium.plugins import MarkerCluster
|
|
import os
|
|
import gpxpy
|
|
import re
|
|
import sys
|
|
import markdown2
|
|
import numpy as np
|
|
import html
|
|
from urllib.parse import quote
|
|
|
|
# --- 1. CONFIGURATION ---
|
|
base_dir = sys.argv[1] if len(sys.argv) > 1 else "./"
|
|
base_dir = os.path.abspath(base_dir)
|
|
|
|
routes_dir = os.path.join(base_dir, "routes")
|
|
html_output_dir = os.path.join(base_dir, "html")
|
|
csv_videos = os.path.join(base_dir, "export_videos.csv")
|
|
csv_photos = os.path.join(base_dir, "export_photos.csv")
|
|
file_notes = os.path.join(base_dir, "voyage.md")
|
|
|
|
if not os.path.exists(html_output_dir):
|
|
os.makedirs(html_output_dir)
|
|
|
|
COULEURS_JOURS = ['#FF5733', '#2ECC71', '#3498DB', '#9B59B6', '#F1C40F', '#E67E22', '#1ABC9C', '#34495E']
|
|
noms_colonnes = ['Fichier', 'Date_Heure', 'Latitude', 'Longitude', 'Altitude', 'Vitesse']
|
|
|
|
# --- ÉCHAPPEMENT ---
|
|
# Sans ça, un fichier nommé « col & vue.jpg » produit un lien cassé et du HTML
|
|
# invalide. quote() encode l'espace et l'esperluette pour les URL, escape()
|
|
# protège le texte affiché.
|
|
def est_sidecar(nom):
|
|
"""Fichiers parasites macOS.
|
|
|
|
Sur un disque non-HFS (exFAT d'un disque externe, cle USB, partage reseau),
|
|
macOS depose un fichier AppleDouble « ._nom » a cote de chaque fichier pour
|
|
y stocker les metadonnees. « ._Ete_2026-08-01.gpx » n'est donc pas un GPX
|
|
mais un blob binaire, et gpxpy echoue dessus avec une erreur de decodage.
|
|
On ecarte aussi .DS_Store et les fichiers caches.
|
|
"""
|
|
return nom.startswith('._') or nom.startswith('.')
|
|
|
|
|
|
def url(chemin):
|
|
"""Chemin sûr pour un attribut src/href."""
|
|
return html.escape(quote(chemin))
|
|
|
|
|
|
def txt(valeur):
|
|
"""Texte sûr pour l'affichage."""
|
|
return html.escape(str(valeur))
|
|
|
|
|
|
# --- FONCTIONS UTILES ---
|
|
def nettoyer_et_trier(df):
|
|
if df is None or df.empty:
|
|
return pd.DataFrame(columns=noms_colonnes + ['DT', 'Jour'])
|
|
df = df[(df['Latitude'].notnull()) & (df['Longitude'].notnull())]
|
|
df = df[(df['Latitude'] != 0) & (df['Longitude'] != 0)]
|
|
df['DT'] = pd.to_datetime(df['Date_Heure'].str.split('.').str[0], format='%Y:%m:%d %H:%M:%S', errors='coerce')
|
|
df = df.dropna(subset=['DT'])
|
|
df['Jour'] = df['DT'].dt.date
|
|
return df.sort_values(by='DT')
|
|
|
|
def create_pin(color, icon_name):
|
|
html_code = f"""<div style="position: relative; height: 40px; width: 24px;">
|
|
<div style="background-color: {color}; width: 24px; height: 24px; border-radius: 50%; border: 2px solid white;
|
|
display: flex; align-items: center; justify-content: center; color: white; font-size: 12px;
|
|
position: absolute; top: 0; z-index: 2; box-shadow: 0 2px 4px rgba(0,0,0,0.4);">
|
|
<i class="fa fa-{icon_name}"></i>
|
|
</div>
|
|
<div style="width: 3px; height: 18px; background-color: {color}; position: absolute; top: 22px; left: 10.5px; z-index: 1;"></div>
|
|
</div>"""
|
|
return folium.DivIcon(html=html_code, icon_anchor=(12, 40))
|
|
|
|
def inject_common_assets(m):
|
|
custom_js = """
|
|
<script>
|
|
// --- DÉTECTION HEVC ---
|
|
// Stratégie hybride : le 720p (H.264) est chargé par défaut et se lit
|
|
// partout. Seul le 1080p est en HEVC. Si le navigateur ne sait pas décoder
|
|
// le HEVC (Firefox principalement), on neutralise le bouton 1080p —
|
|
// la vidéo reste lisible, seule l'option haute qualité disparaît.
|
|
var HEVC_OK = (function () {
|
|
try {
|
|
var v = document.createElement('video');
|
|
var t = v.canPlayType('video/mp4; codecs="hvc1.1.6.L120.B0"');
|
|
return t === 'probably' || t === 'maybe';
|
|
} catch (e) { return false; }
|
|
})();
|
|
|
|
function applyCodecSupport(popup) {
|
|
if (HEVC_OK) return;
|
|
var btns = popup.querySelectorAll('.quality-btn[data-hevc="1"]');
|
|
for (var i = 0; i < btns.length; i++) {
|
|
btns[i].classList.add('qbtn-disabled');
|
|
btns[i].title = "Le 1080p est encodé en HEVC, que ce navigateur ne sait pas décoder. Essaie Safari, Chrome ou Edge.";
|
|
}
|
|
var note = popup.querySelector('.hevc-note');
|
|
if (note) note.style.display = 'block';
|
|
}
|
|
|
|
// --- GESTION DES BOUTONS 720p / 1080p ---
|
|
function changeQuality(videoId, newSrc, btnElement) {
|
|
if (btnElement.classList.contains('qbtn-disabled')) return;
|
|
|
|
var video = document.getElementById(videoId);
|
|
|
|
// On compare les NOMS DE FICHIER, pas les chemins : video.src est une
|
|
// URL absolue résolue par le navigateur, alors que newSrc est relatif
|
|
// ("../videos/X.mp4"). L'ancien test `video.src.includes(newSrc)` ne
|
|
// pouvait donc jamais être vrai, et recliquer sur la qualité déjà
|
|
// active rechargeait la vidéo en repartant du début.
|
|
var nomActuel = decodeURIComponent(video.src).split('/').pop();
|
|
var nomVoulu = decodeURIComponent(newSrc).split('/').pop();
|
|
if (nomActuel === nomVoulu) return;
|
|
|
|
var savedTime = video.currentTime;
|
|
var wasPlaying = !video.paused;
|
|
|
|
video.src = newSrc;
|
|
|
|
video.onloadedmetadata = function() {
|
|
this.currentTime = savedTime;
|
|
if (wasPlaying) {
|
|
var playPromise = this.play();
|
|
if (playPromise !== undefined) {
|
|
playPromise.catch(error => { console.log("Lecture bloquée"); });
|
|
}
|
|
}
|
|
this.onloadedmetadata = null;
|
|
};
|
|
|
|
video.load();
|
|
|
|
var container = btnElement.parentElement;
|
|
var buttons = container.getElementsByClassName('quality-btn');
|
|
for (var i = 0; i < buttons.length; i++) {
|
|
buttons[i].classList.remove('active-720', 'active-1080');
|
|
buttons[i].style.opacity = "0.6";
|
|
}
|
|
|
|
btnElement.style.opacity = "1";
|
|
if (btnElement.innerText.includes("720p")) {
|
|
btnElement.classList.add('active-720');
|
|
} else {
|
|
btnElement.classList.add('active-1080');
|
|
}
|
|
}
|
|
|
|
// --- AUTOPLAY INTELLIGENT (DÉTECTION D'OUVERTURE POPUP) ---
|
|
// Cet observateur surveille l'apparition des popups.
|
|
// Dès qu'une popup s'ouvre, il trouve la vidéo et lance la lecture.
|
|
var observer = new MutationObserver(function(mutations) {
|
|
mutations.forEach(function(mutation) {
|
|
mutation.addedNodes.forEach(function(node) {
|
|
// Si l'élément ajouté est une popup Leaflet
|
|
if (node.nodeType === 1 && node.classList && node.classList.contains('leaflet-popup')) {
|
|
applyCodecSupport(node);
|
|
var video = node.querySelector('video');
|
|
if (video) {
|
|
// Filet de sécurité : si le 1080p HEVC échoue malgré la
|
|
// détection, on retombe automatiquement sur le 720p H.264.
|
|
video.addEventListener('error', function () {
|
|
var fb = this.getAttribute('data-fallback');
|
|
if (fb && this.src.indexOf(fb) === -1) { this.src = fb; this.load(); }
|
|
});
|
|
console.log("🎬 Popup ouverte : Lancement de la vidéo...");
|
|
var playPromise = video.play();
|
|
if (playPromise !== undefined) {
|
|
playPromise.catch(error => {
|
|
console.log("Autoplay bloqué par le navigateur (interaction requise ?)", error);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// On lance l'espion dès que la page est chargée
|
|
document.addEventListener("DOMContentLoaded", function() {
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
});
|
|
// ----------------------------------------
|
|
|
|
function toggleJournal(open) {
|
|
var panel = document.getElementById('journal-panel');
|
|
if (open) {
|
|
panel.classList.add('open');
|
|
localStorage.setItem('journalOpen', 'true');
|
|
} else {
|
|
panel.classList.remove('open');
|
|
localStorage.setItem('journalOpen', 'false');
|
|
}
|
|
}
|
|
|
|
window.addEventListener('DOMContentLoaded', function() {
|
|
if (localStorage.getItem('journalOpen') === 'true') {
|
|
var panel = document.getElementById('journal-panel');
|
|
if (panel) panel.classList.add('open');
|
|
}
|
|
});
|
|
|
|
function moveSlide(btn, step) {
|
|
var container = btn.parentElement.querySelector('.slides');
|
|
var slides = container.querySelectorAll('.slide');
|
|
var activeIndex = Array.from(slides).findIndex(s => s.style.display !== 'none');
|
|
if (activeIndex === -1) activeIndex = 0;
|
|
slides[activeIndex].style.display = 'none';
|
|
var nextIndex = (activeIndex + step + slides.length) % slides.length;
|
|
slides[nextIndex].style.display = 'block';
|
|
btn.parentElement.querySelector('.slide-counter').innerText = (nextIndex + 1) + '/' + slides.length;
|
|
}
|
|
</script>
|
|
"""
|
|
custom_css = """
|
|
<style>
|
|
.header-row {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
background: #f0f0f0; padding: 5px 10px; border-radius: 4px; margin-bottom: 5px;
|
|
}
|
|
.file-name {
|
|
font-size: 11px; font-family: monospace; font-weight: bold; color: #333;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%;
|
|
}
|
|
.quality-controls { display: flex; gap: 6px; }
|
|
.quality-btn {
|
|
padding: 3px 8px; border-radius: 12px; font-size: 10px; font-weight: bold; cursor: pointer;
|
|
border: 1px solid #ccc; background: #fff; opacity: 0.6; transition: 0.2s; font-family: sans-serif;
|
|
text-transform: uppercase;
|
|
}
|
|
.quality-btn:hover { opacity: 1; background: #eee; }
|
|
.active-720 { background-color: #2ECC71 !important; color: white; border-color: #27ae60 !important; opacity: 1 !important; }
|
|
.active-1080 { background-color: #007AFF !important; color: white; border-color: #0056b3 !important; opacity: 1 !important; }
|
|
|
|
.qbtn-disabled {
|
|
opacity: 0.35 !important; cursor: not-allowed !important;
|
|
text-decoration: line-through; background: #eee !important;
|
|
}
|
|
.qbtn-disabled:hover { opacity: 0.35 !important; background: #eee !important; }
|
|
.hevc-note {
|
|
display: none; margin-top: 6px; font-family: sans-serif; font-size: 11px;
|
|
color: #8a6d3b; background: #fcf8e3; border-radius: 6px; padding: 6px 10px;
|
|
text-align: left;
|
|
}
|
|
|
|
.leaflet-top.leaflet-left { top: auto !important; bottom: 20px !important; left: 15px !important; }
|
|
.leaflet-popup-content-wrapper { border-radius: 12px; max-width: 95vw !important; }
|
|
#journal-panel {
|
|
position: fixed; top: 0; right: -100%; width: 400px; height: 100%;
|
|
background: white; z-index: 10005; transition: 0.4s;
|
|
box-shadow: -5px 0 15px rgba(0,0,0,0.2); padding: 25px;
|
|
overflow-y: auto; font-family: sans-serif;
|
|
}
|
|
#journal-panel.open { right: 0; }
|
|
@media (max-width: 600px) { #journal-panel { width: 85%; } }
|
|
.close-btn { position: absolute; top: 15px; right: 15px; font-size: 24px; cursor: pointer; border: none; background: none; }
|
|
.slider-container { position: relative; width: calc(85vw - 60px); max-width: 800px; text-align: center; min-height: 200px; }
|
|
.slide img { max-width: 100%; max-height: 65vh; border-radius: 8px; display: block; margin: auto; }
|
|
.nav-btn { position: absolute; top: 50%; transform: translateY(-50%); background: rgba(0,0,0,0.6); color: white; border: 2px solid white; padding: 12px; cursor: pointer; border-radius: 50%; z-index: 10; font-size: 18px; }
|
|
.prev { left: -10px; } .next { right: -10px; }
|
|
.slide-counter { margin-top: 8px; font-size: 13px; font-weight: bold; color: #333; font-family: sans-serif; }
|
|
</style>
|
|
"""
|
|
m.get_root().header.add_child(folium.Element(custom_css + custom_js))
|
|
|
|
def get_journal_ui():
|
|
journal_html = ""
|
|
if os.path.exists(file_notes):
|
|
with open(file_notes, 'r', encoding='utf-8') as f:
|
|
journal_html = markdown2.markdown(f.read())
|
|
return f"""
|
|
<div id="journal-panel"><button class="close-btn" onclick="toggleJournal(false)">×</button><div class="journal-content">{journal_html}</div></div>
|
|
<div style="position: fixed; bottom: 25px; right: 15px; z-index: 10000; display: flex; flex-direction: column; align-items: flex-end; gap: 10px;">
|
|
<button onclick="toggleJournal(true)" style="background: #FFD700; border: 2px solid white; padding: 12px 20px; border-radius: 50px; font-weight: bold; font-size: 14px; cursor: pointer; box-shadow: 0 4px 15px rgba(0,0,0,0.3); font-family:sans-serif;">📖 Journal</button>
|
|
</div>
|
|
"""
|
|
|
|
# --- 2. CHARGEMENT ---
|
|
try:
|
|
df_v = pd.read_csv(csv_videos, names=noms_colonnes, header=None) if os.path.exists(csv_videos) else pd.DataFrame()
|
|
df_p = pd.read_csv(csv_photos, names=noms_colonnes, header=None) if os.path.exists(csv_photos) else pd.DataFrame()
|
|
df_v, df_p = nettoyer_et_trier(df_v), nettoyer_et_trier(df_p)
|
|
|
|
jours_gpx = set()
|
|
if os.path.exists(routes_dir):
|
|
for f in os.listdir(routes_dir):
|
|
if est_sidecar(f):
|
|
continue
|
|
match = re.search(r'(\d{4}-\d{2}-\d{2})', f)
|
|
if match: jours_gpx.add(pd.to_datetime(match.group(1)).date())
|
|
tous_les_jours = sorted(list(set(df_v['Jour'].unique()) | set(df_p['Jour'].unique()) | jours_gpx))
|
|
except Exception as e:
|
|
print(f"❌ Erreur : {e}"); sys.exit()
|
|
|
|
# --- 3. GÉNÉRATION ---
|
|
m_global = folium.Map(tiles=None)
|
|
folium.TileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attr='Esri', name='Satellite').add_to(m_global)
|
|
folium.TileLayer('OpenStreetMap', name='Plan').add_to(m_global)
|
|
inject_common_assets(m_global)
|
|
|
|
m_mini = folium.Map(tiles='OpenStreetMap', zoom_control=False, control_scale=False, attribution_control=False)
|
|
|
|
all_global_coords, sidebar_links_html = [], ""
|
|
|
|
for i, jour in enumerate(tous_les_jours):
|
|
day_str = jour.strftime('%Y-%m-%d')
|
|
file_name = f'carte_{day_str}.html'
|
|
color_day = COULEURS_JOURS[i % len(COULEURS_JOURS)]
|
|
sidebar_links_html += f'<a href="html/{file_name}" target="_top" style="display:block; margin-bottom:8px; padding:12px; background:{color_day}; color:white; text-decoration:none; border-radius:8px; font-weight:bold; text-align:center; font-family:sans-serif;">{day_str}</a>'
|
|
|
|
m_day = folium.Map(tiles=None)
|
|
folium.TileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attr='Esri', name='Satellite').add_to(m_day)
|
|
folium.TileLayer('OpenStreetMap', name='Plan').add_to(m_day)
|
|
inject_common_assets(m_day)
|
|
|
|
marker_cluster = MarkerCluster(name="Médias").add_to(m_day)
|
|
day_coords = []
|
|
media_coords = [] # position des photos/vidéos, filet de cadrage
|
|
current_gpx_filename = None
|
|
|
|
if os.path.exists(routes_dir):
|
|
for gpx_file in os.listdir(routes_dir):
|
|
# `day_str in gpx_file` et non `.startswith(day_str)` : la liste des
|
|
# jours est bâtie avec re.search (date N'IMPORTE OÙ dans le nom),
|
|
# donc une trace « trace_2026-07-15_col.gpx » créait bien un jour
|
|
# mais n'était jamais retrouvée ici — jour vide, carte non cadrée.
|
|
if est_sidecar(gpx_file):
|
|
continue
|
|
if day_str in gpx_file and gpx_file.lower().endswith('.gpx'):
|
|
try:
|
|
with open(os.path.join(routes_dir, gpx_file), 'r') as f:
|
|
gpx = gpxpy.parse(f)
|
|
for track in gpx.tracks:
|
|
pts = []
|
|
for segment in track.segments:
|
|
pts.extend([[p.latitude, p.longitude] for p in segment.points])
|
|
if pts:
|
|
day_coords.extend(pts); all_global_coords.extend(pts)
|
|
popup_html = f"<div style='text-align:center; font-family:sans-serif;'><b>📅 {day_str}</b><br><br><a href='html/{file_name}' target='_top' style='background:{color_day}; color:white; padding:8px 12px; border-radius:4px; text-decoration:none; font-weight:bold;'>Détails</a></div>"
|
|
folium.PolyLine(pts, color=color_day, weight=7, opacity=0.8, popup=folium.Popup(popup_html, max_width=200)).add_to(m_global)
|
|
folium.PolyLine(pts, color=color_day, weight=3, opacity=0.8).add_to(m_mini)
|
|
folium.PolyLine(pts, color="#FF0000", weight=4, opacity=0.8).add_to(m_day)
|
|
# Le nom n'est retenu qu'APRES analyse reussie : sinon le
|
|
# bouton de telechargement pouvait pointer sur un fichier
|
|
# illisible traite apres la vraie trace.
|
|
current_gpx_filename = gpx_file
|
|
except Exception as e:
|
|
# `except: pass` avalait TOUT, y compris un Ctrl-C, et
|
|
# masquait les GPX corrompus : la trace disparaissait sans
|
|
# le moindre message.
|
|
print(f" ⚠️ GPX illisible ({gpx_file}) : {e}")
|
|
|
|
m_day.get_root().html.add_child(folium.Element('<div style="position: fixed; top: 15px; left: 15px; z-index: 10001;"><a href="../index.html" target="_top" style="text-decoration: none; background: #333; color: white; padding: 12px 20px; border-radius: 10px; font-family: sans-serif; font-weight: bold; box-shadow: 0 4px 10px rgba(0,0,0,0.5); border: 2px solid white; display: block;">🏠 Accueil</a></div>' + get_journal_ui()))
|
|
|
|
if current_gpx_filename:
|
|
m_day.get_root().html.add_child(folium.Element(f'<div style="position: fixed; bottom: 85px; right: 15px; z-index: 10001;"><a href="../routes/{url(current_gpx_filename)}" download style="text-decoration: none; background: #28a745; color: white; padding: 12px 20px; border-radius: 50px; font-family: sans-serif; font-weight: bold; box-shadow: 0 4px 15px rgba(0,0,0,0.4); border: 2px solid white; display: block;">📥 GPX</a></div>'))
|
|
|
|
# REGROUPEMENT PHOTOS
|
|
day_p = df_p[df_p['Jour'] == jour].copy()
|
|
if not day_p.empty:
|
|
tol = 0.0002
|
|
while not day_p.empty:
|
|
ref = day_p.iloc[0]
|
|
masque = (np.abs(day_p['Latitude'] - ref['Latitude']) < tol) & (np.abs(day_p['Longitude'] - ref['Longitude']) < tol)
|
|
groupe = day_p[masque]
|
|
fichiers = groupe['Fichier'].tolist()
|
|
nb = len(fichiers)
|
|
slides = "".join([
|
|
f'<div class="slide" style="display:{"block" if idx == 0 else "none"};">'
|
|
f'<div class="file-header">{txt(p.strip())}</div>'
|
|
f'<a href="../photos/{url(p.strip())}" target="_blank">'
|
|
f'<img src="../photos/{url(p.strip())}" alt="{txt(p.strip())}" loading="lazy"></a></div>'
|
|
for idx, p in enumerate(fichiers)])
|
|
btns = f'<button class="nav-btn prev" onclick="moveSlide(this, -1)">❮</button><button class="nav-btn next" onclick="moveSlide(this, 1)">❯</button><div class="slide-counter">1/{nb}</div>' if nb > 1 else ""
|
|
media_coords.append([ref['Latitude'], ref['Longitude']])
|
|
folium.Marker(location=[ref['Latitude'], ref['Longitude']], popup=folium.Popup(f'<div class="slider-container"><div class="slides">{slides}</div>{btns}</div>', max_width="100%"), icon=create_pin("#FF3B30", "camera" if nb==1 else "images")).add_to(marker_cluster)
|
|
day_p = day_p[~masque]
|
|
|
|
# Vidéos
|
|
day_v = df_v[df_v['Jour'] == jour]
|
|
for v_name, group in day_v.groupby('Fichier'):
|
|
pts_v = group[['Latitude', 'Longitude']].values.tolist()
|
|
|
|
base_name = os.path.splitext(v_name.strip())[0]
|
|
|
|
clean_id = re.sub(r'[^a-zA-Z0-9]', '', base_name)
|
|
video_id = f"vid_{clean_id}"
|
|
|
|
desktop_file = f"../videos/{url(base_name)}.mp4"
|
|
mobile_file = f"../videos/{url(base_name)}_mobile.mp4"
|
|
media_coords.append(pts_v[0])
|
|
|
|
# Ajout 'autoplay muted' pour le lancement au clic
|
|
# Ajout 'preload=none' pour ne pas charger les autres
|
|
# --- BLOC VIDÉO FIABLE (Défaut = Léger, Upgrade manuel) ---
|
|
v_pop = (
|
|
f'<div style="width:calc(85vw - 40px); max-width:1000px; text-align:center;">'
|
|
|
|
f'<div class="header-row">'
|
|
f'<span class="file-name" title="{txt(v_name.strip())}">{txt(v_name.strip())}</span>'
|
|
f'<div class="quality-controls">'
|
|
# Le bouton 720p est actif par défaut (cohérent avec le fichier chargé)
|
|
f'<span class="quality-btn active-720" onclick="changeQuality(\'{video_id}\', \'{mobile_file}\', this)">720p</span>'
|
|
# data-hevc : ce palier est en H.265, il sera grisé si le
|
|
# navigateur ne sait pas le décoder.
|
|
f'<span class="quality-btn" data-hevc="1" onclick="changeQuality(\'{video_id}\', \'{desktop_file}\', this)">1080p</span>'
|
|
f'</div>'
|
|
f'</div>'
|
|
|
|
# 1. On charge par défaut le fichier MOBILE (léger) pour garantir la lecture partout.
|
|
# 2. preload="none" : Indispensable pour que la page charge vite.
|
|
# 3. Pas d'autoplay.
|
|
f'<video id="{video_id}" style="width:100%; max-height:70vh; border-radius:8px; background:black;" '
|
|
f'controls playsinline webkit-playsinline preload="none" '
|
|
f'src="{mobile_file}" data-fallback="{mobile_file}">' # <-- 720p H.264 : lisible partout
|
|
f'</video>'
|
|
|
|
# Note discrète affichée uniquement si le 1080p est indisponible
|
|
f'<div class="hevc-note">'
|
|
f'Le 1080p est encodé en H.265/HEVC, que ce navigateur ne sait pas décoder. '
|
|
f'La lecture en 720p fonctionne normalement ; pour la haute définition, '
|
|
f'utilise Safari, Chrome ou Edge.'
|
|
f'</div>'
|
|
|
|
f'</div>'
|
|
)
|
|
folium.Marker(location=pts_v[0], popup=folium.Popup(v_pop, max_width="100%"), icon=create_pin("#007AFF", "play")).add_to(marker_cluster)
|
|
|
|
# Cadrage : la trace GPX en priorité, sinon la position des médias.
|
|
# Sans ce repli, un jour sans GPX (mais avec photos) s'ouvrait sur une
|
|
# mappemonde centrée au large de l'Afrique.
|
|
cadrage = day_coords if day_coords else media_coords
|
|
if cadrage:
|
|
m_day.fit_bounds(cadrage, padding=(50, 50))
|
|
all_global_coords.extend(media_coords)
|
|
|
|
folium.LayerControl(position='topright').add_to(m_day)
|
|
plugins.Fullscreen(position='topright').add_to(m_day)
|
|
m_day.save(os.path.join(html_output_dir, file_name))
|
|
|
|
# --- FINALISATION ---
|
|
if all_global_coords:
|
|
m_global.fit_bounds(all_global_coords, padding=(50, 50))
|
|
m_mini.fit_bounds(all_global_coords, padding=(20, 20))
|
|
|
|
sidebar_html = f'<div style="position: fixed; top: 15px; left: 15px; width: 160px; max-height: 85vh; overflow-y: auto; background: rgba(255,255,255,0.9); z-index: 9999; padding: 12px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.2);"><a href="../" target="_top" style="display:block; margin-bottom:15px; padding:12px; background:#444; color:white; text-decoration:none; border-radius:8px; font-weight:bold; text-align:center; border: 2px solid #666; font-family:sans-serif;">⬅ Retour</a><div style="border-top: 1px solid #ccc; margin-bottom: 10px; padding-top: 10px; font-family:sans-serif; font-size:12px; font-weight:bold; color:#666; text-align:center;">PAR JOURS</div>{sidebar_links_html}</div>'
|
|
m_global.get_root().html.add_child(folium.Element(sidebar_html + get_journal_ui()))
|
|
folium.LayerControl().add_to(m_global)
|
|
|
|
m_global.save(os.path.join(base_dir, "index.html"))
|
|
m_mini.save(os.path.join(base_dir, "mini.html"))
|
|
|
|
|
|
|