feat: v2 multi-session parser + timeline range viewer
This commit is contained in:
@@ -2,59 +2,128 @@
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>COSMA — USV Track Viewer</title>
|
||||
<title>COSMA — USV Multi-Track Viewer v2</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.css"/>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: monospace; background: #1a1a2e; color: #e0e0e0; display: flex; flex-direction: column; height: 100vh; }
|
||||
#map { flex: 1; }
|
||||
#map { flex: 1; min-height: 0; }
|
||||
#controls {
|
||||
background: #16213e;
|
||||
padding: 8px 12px;
|
||||
padding: 10px 14px 8px;
|
||||
border-top: 1px solid #0f3460;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-top: 1px solid #0f3460;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#slider { flex: 1; cursor: pointer; }
|
||||
#info { font-size: 11px; min-width: 340px; color: #a0c4ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
#title { font-size: 13px; font-weight: bold; color: #e94560; white-space: nowrap; }
|
||||
#stats { font-size: 11px; color: #a0c4ff; flex: 1; }
|
||||
#btn-reset {
|
||||
background: #0f3460; border: 1px solid #00b4d8; color: #00b4d8;
|
||||
padding: 3px 10px; cursor: pointer; font-family: monospace; font-size: 11px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
#btn-reset:hover { background: #00b4d8; color: #1a1a2e; }
|
||||
|
||||
/* Range slider row */
|
||||
#range-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
#range-label { font-size: 10px; color: #666; white-space: nowrap; }
|
||||
#range-slider-wrap { flex: 1; padding: 4px 0; }
|
||||
#range-dates { display: flex; justify-content: space-between; font-size: 10px; color: #a0c4ff; }
|
||||
|
||||
/* Cursor slider row */
|
||||
#cursor-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
#cursor-label { font-size: 10px; color: #666; white-space: nowrap; }
|
||||
#cursor-slider-wrap { flex: 1; }
|
||||
#cursor-info { font-size: 11px; color: #e0e0e0; min-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* noUiSlider overrides */
|
||||
.noUi-target { background: #0f3460; border: none; border-radius: 2px; box-shadow: none; }
|
||||
.noUi-horizontal { height: 6px; }
|
||||
.noUi-connect { background: #00b4d8; }
|
||||
.noUi-handle {
|
||||
background: #00b4d8; border: 2px solid #fff;
|
||||
border-radius: 50%; width: 14px !important; height: 14px !important;
|
||||
top: -5px !important; right: -7px !important;
|
||||
box-shadow: none; cursor: pointer;
|
||||
}
|
||||
.noUi-handle::before, .noUi-handle::after { display: none; }
|
||||
#cursor-slider .noUi-connect { background: #e94560; }
|
||||
#cursor-slider .noUi-handle { background: #e94560; }
|
||||
|
||||
/* Legend */
|
||||
#legend {
|
||||
position: absolute; bottom: 200px; left: 10px; z-index: 1000;
|
||||
background: rgba(22,33,62,0.92); padding: 8px 10px; border: 1px solid #0f3460;
|
||||
font-size: 11px; font-family: monospace;
|
||||
display: none;
|
||||
}
|
||||
.legend-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
|
||||
.legend-dot { width: 12px; height: 4px; border-radius: 2px; flex-shrink: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
<div id="legend"></div>
|
||||
<div id="controls">
|
||||
<span id="title">USV Track</span>
|
||||
<input type="range" id="slider" min="0" value="0">
|
||||
<div id="info">Chargement…</div>
|
||||
<div id="top-row">
|
||||
<span id="title">USV Multi-Track v2</span>
|
||||
<span id="stats">Chargement…</span>
|
||||
<button id="btn-reset">Reset window</button>
|
||||
</div>
|
||||
<div id="range-row">
|
||||
<span id="range-label">Fenêtre</span>
|
||||
<div id="range-slider-wrap"><div id="range-slider"></div></div>
|
||||
</div>
|
||||
<div id="range-dates">
|
||||
<span id="date-start">—</span>
|
||||
<span id="date-end">—</span>
|
||||
</div>
|
||||
<div id="cursor-row">
|
||||
<span id="cursor-label">Curseur</span>
|
||||
<div id="cursor-slider-wrap"><div id="cursor-slider"></div></div>
|
||||
<div id="cursor-info">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.js"></script>
|
||||
<script>
|
||||
// ── Constants ────────────────────────────────────────────────────────────
|
||||
const COLORS = ['#00b4d8','#e94560','#06d6a0','#ffd166','#a855f7','#f97316'];
|
||||
|
||||
// ── Map init ──────────────────────────────────────────────────────────────
|
||||
const map = L.map('map', { zoomControl: true });
|
||||
|
||||
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors', maxZoom: 19,
|
||||
});
|
||||
osm.addTo(map);
|
||||
|
||||
const seamarks = L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenSeaMap',
|
||||
maxZoom: 18,
|
||||
opacity: 0.8,
|
||||
attribution: '© OpenSeaMap', maxZoom: 18, opacity: 0.8,
|
||||
});
|
||||
|
||||
// GEBCO bathymetry (tile-based, simpler than WMS)
|
||||
const gebco = L.tileLayer(
|
||||
'https://tiles.arcgis.com/tiles/C8EMgrsFcRFL6LrL/arcgis/rest/services/GEBCO_basemap_NCEI/MapServer/tile/{z}/{y}/{x}',
|
||||
{
|
||||
attribution: 'GEBCO / NCEI',
|
||||
maxZoom: 13,
|
||||
opacity: 0.6,
|
||||
}
|
||||
{ attribution: 'GEBCO / NCEI', maxZoom: 13, opacity: 0.6 }
|
||||
);
|
||||
|
||||
L.control.layers(
|
||||
@@ -63,79 +132,207 @@ L.control.layers(
|
||||
{ collapsed: false }
|
||||
).addTo(map);
|
||||
|
||||
// ── SVG arrow marker factory ──────────────────────────────────────────────
|
||||
function makeArrowIcon(heading) {
|
||||
// ── Arrow marker ──────────────────────────────────────────────────────────
|
||||
function makeArrowIcon(heading, color) {
|
||||
color = color || '#e94560';
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="-16 -16 32 32">
|
||||
<g transform="rotate(${heading || 0})">
|
||||
<polygon points="0,-12 6,8 0,4 -6,8" fill="#e94560" stroke="#fff" stroke-width="1.5"/>
|
||||
<polygon points="0,-12 6,8 0,4 -6,8" fill="${color}" stroke="#fff" stroke-width="1.5"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
return L.divIcon({
|
||||
html: svg,
|
||||
className: '',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
return L.divIcon({ html: svg, className: '', iconSize: [32,32], iconAnchor: [16,16] });
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
let allPoints = []; // all sampled points sorted by t_ms
|
||||
let manifest = null;
|
||||
let trackLayers = []; // per-session polyline layers
|
||||
let windowPoints = []; // filtered by range window
|
||||
let cursorMarker = null;
|
||||
let rangeSlider = null;
|
||||
let cursorSlider = null;
|
||||
let tMin = 0, tMax = 0;
|
||||
|
||||
// ── Utils ─────────────────────────────────────────────────────────────────
|
||||
function fmtMs(ms) {
|
||||
if (!ms) return '—';
|
||||
return new Date(ms).toISOString().replace('T',' ').slice(0,19) + ' UTC';
|
||||
}
|
||||
function fmtDuration(ms) {
|
||||
const s = Math.floor(ms / 1000);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sc = s % 60;
|
||||
return h > 0 ? `${h}h${String(m).padStart(2,'0')}m` : `${m}m${String(sc).padStart(2,'0')}s`;
|
||||
}
|
||||
|
||||
// ── Range window filtering ────────────────────────────────────────────────
|
||||
function filterPointsInWindow(startMs, endMs) {
|
||||
return allPoints.filter(p => p.t_ms >= startMs && p.t_ms <= endMs);
|
||||
}
|
||||
|
||||
function updateWindow(startMs, endMs) {
|
||||
document.getElementById('date-start').textContent = fmtMs(startMs);
|
||||
document.getElementById('date-end').textContent = fmtMs(endMs);
|
||||
|
||||
windowPoints = filterPointsInWindow(startMs, endMs);
|
||||
|
||||
// Rebuild track layers by session
|
||||
trackLayers.forEach(l => map.removeLayer(l));
|
||||
trackLayers = [];
|
||||
|
||||
if (!manifest) return;
|
||||
|
||||
const sessionGroups = {};
|
||||
windowPoints.forEach(p => {
|
||||
if (!sessionGroups[p.source]) sessionGroups[p.source] = [];
|
||||
sessionGroups[p.source].push(p);
|
||||
});
|
||||
|
||||
manifest.sessions.forEach((sess, i) => {
|
||||
const pts = sessionGroups[sess.source_name] || [];
|
||||
if (pts.length < 2) return;
|
||||
const coords = pts.map(p => [p.lat, p.lon]);
|
||||
const color = COLORS[i % COLORS.length];
|
||||
const layer = L.polyline(coords, { color, weight: 2.5, opacity: 0.85 }).addTo(map);
|
||||
trackLayers.push(layer);
|
||||
});
|
||||
|
||||
// Update cursor slider range
|
||||
if (cursorSlider && windowPoints.length > 0) {
|
||||
cursorSlider.updateOptions({
|
||||
range: { min: 0, max: Math.max(windowPoints.length - 1, 1) },
|
||||
});
|
||||
cursorSlider.set(0);
|
||||
}
|
||||
|
||||
updateStats(startMs, endMs);
|
||||
}
|
||||
|
||||
function updateStats(startMs, endMs) {
|
||||
const dur = endMs - startMs;
|
||||
document.getElementById('stats').textContent =
|
||||
`${windowPoints.length} pts dans fenêtre | durée: ${fmtDuration(dur)} | sessions: ${manifest ? manifest.n_sessions : '?'}`;
|
||||
}
|
||||
|
||||
// ── Cursor ────────────────────────────────────────────────────────────────
|
||||
function updateCursor(idx) {
|
||||
idx = Math.max(0, Math.min(idx, windowPoints.length - 1));
|
||||
const p = windowPoints[idx];
|
||||
if (!p) return;
|
||||
|
||||
// Find session color
|
||||
const sessIdx = manifest ? manifest.sessions.findIndex(s => s.source_name === p.source) : 0;
|
||||
const color = COLORS[Math.max(0, sessIdx) % COLORS.length];
|
||||
|
||||
if (!cursorMarker) {
|
||||
cursorMarker = L.marker([p.lat, p.lon], { icon: makeArrowIcon(p.heading || 0, color), zIndexOffset: 1000 }).addTo(map);
|
||||
} else {
|
||||
cursorMarker.setLatLng([p.lat, p.lon]);
|
||||
cursorMarker.setIcon(makeArrowIcon(p.heading || 0, color));
|
||||
}
|
||||
|
||||
const hdg = p.heading !== null && p.heading !== undefined ? `${p.heading.toFixed(1)}°` : 'N/A';
|
||||
document.getElementById('cursor-info').textContent =
|
||||
`[${idx+1}/${windowPoints.length}] ${p.t} | ${p.lat.toFixed(6)}, ${p.lon.toFixed(6)} | cap: ${hdg} | ${p.source}`;
|
||||
}
|
||||
|
||||
// ── Legend ────────────────────────────────────────────────────────────────
|
||||
function buildLegend() {
|
||||
if (!manifest || manifest.n_sessions <= 1) return;
|
||||
const el = document.getElementById('legend');
|
||||
el.style.display = 'block';
|
||||
el.innerHTML = manifest.sessions.map((s, i) => {
|
||||
const color = COLORS[i % COLORS.length];
|
||||
const name = s.source_name.replace('_navigation_log', '').replace('.csv', '');
|
||||
return `<div class="legend-item"><div class="legend-dot" style="background:${color}"></div><span>${name}</span></div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Init sliders ──────────────────────────────────────────────────────────
|
||||
function initSliders() {
|
||||
// Range slider
|
||||
rangeSlider = noUiSlider.create(document.getElementById('range-slider'), {
|
||||
start: [tMin, tMax],
|
||||
connect: true,
|
||||
range: { min: tMin, max: tMax },
|
||||
step: 1000,
|
||||
behaviour: 'drag',
|
||||
});
|
||||
rangeSlider.on('update', (values) => {
|
||||
updateWindow(+values[0], +values[1]);
|
||||
});
|
||||
|
||||
// Cursor slider (single handle inside window)
|
||||
cursorSlider = noUiSlider.create(document.getElementById('cursor-slider'), {
|
||||
start: [0],
|
||||
range: { min: 0, max: Math.max(allPoints.length - 1, 1) },
|
||||
step: 1,
|
||||
});
|
||||
cursorSlider.on('update', (values) => {
|
||||
updateCursor(Math.round(+values[0]));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Data loading ──────────────────────────────────────────────────────────
|
||||
let points = [];
|
||||
let trackLayer = null;
|
||||
let marker = null;
|
||||
// ── Reset window ──────────────────────────────────────────────────────────
|
||||
document.getElementById('btn-reset').addEventListener('click', () => {
|
||||
if (rangeSlider) rangeSlider.set([tMin, tMax]);
|
||||
});
|
||||
|
||||
// ── Main load ─────────────────────────────────────────────────────────────
|
||||
async function loadData() {
|
||||
try {
|
||||
const [trackResp, pointsResp] = await Promise.all([
|
||||
fetch('track.geojson'),
|
||||
fetch('points.json'),
|
||||
const [trackResp, pointsResp, manifestResp] = await Promise.all([
|
||||
fetch('data/track.geojson'),
|
||||
fetch('data/points.json'),
|
||||
fetch('data/manifest.json'),
|
||||
]);
|
||||
if (!trackResp.ok) throw new Error('track.geojson not found');
|
||||
if (!pointsResp.ok) throw new Error('points.json not found');
|
||||
if (!manifestResp.ok) throw new Error('manifest.json not found');
|
||||
|
||||
const trackGeo = await trackResp.json();
|
||||
points = await pointsResp.json();
|
||||
allPoints = await pointsResp.json();
|
||||
manifest = await manifestResp.json();
|
||||
|
||||
// Draw track
|
||||
trackLayer = L.geoJSON(trackGeo, {
|
||||
style: { color: '#00b4d8', weight: 2, opacity: 0.8 },
|
||||
// Initial full-track display (all sessions)
|
||||
L.geoJSON(trackGeo, {
|
||||
style: (feature) => ({
|
||||
color: feature.properties.color || '#00b4d8',
|
||||
weight: 2,
|
||||
opacity: 0.5,
|
||||
}),
|
||||
}).addTo(map);
|
||||
map.fitBounds(trackLayer.getBounds(), { padding: [40, 40] });
|
||||
|
||||
// Init slider
|
||||
const slider = document.getElementById('slider');
|
||||
slider.max = points.length - 1;
|
||||
slider.value = 0;
|
||||
slider.addEventListener('input', () => updateMarker(+slider.value));
|
||||
|
||||
// Init marker
|
||||
if (points.length > 0) {
|
||||
const p = points[0];
|
||||
marker = L.marker([p.lat, p.lon], { icon: makeArrowIcon(p.heading || 0) }).addTo(map);
|
||||
updateInfo(0);
|
||||
// Fit bounds
|
||||
if (allPoints.length > 0) {
|
||||
const lats = allPoints.map(p => p.lat);
|
||||
const lons = allPoints.map(p => p.lon);
|
||||
map.fitBounds([[Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)]], { padding: [40,40] });
|
||||
}
|
||||
|
||||
document.getElementById('title').textContent = `USV Track — ${points.length} pts`;
|
||||
// Time range
|
||||
tMin = manifest.t_min_ms || Math.min(...allPoints.map(p => p.t_ms || Infinity));
|
||||
tMax = manifest.t_max_ms || Math.max(...allPoints.map(p => p.t_ms || -Infinity));
|
||||
|
||||
if (tMin === tMax) tMax = tMin + 1000; // safety
|
||||
|
||||
document.getElementById('title').textContent =
|
||||
`USV Multi-Track — ${manifest.n_sessions} sessions — ${manifest.n_points_sampled} pts`;
|
||||
|
||||
buildLegend();
|
||||
initSliders();
|
||||
|
||||
// Initial window = full range
|
||||
windowPoints = [...allPoints];
|
||||
updateStats(tMin, tMax);
|
||||
|
||||
} catch (e) {
|
||||
document.getElementById('info').textContent = 'Erreur: ' + e.message;
|
||||
document.getElementById('stats').textContent = 'Erreur: ' + e.message;
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function updateMarker(idx) {
|
||||
const p = points[idx];
|
||||
if (!p || !marker) return;
|
||||
marker.setLatLng([p.lat, p.lon]);
|
||||
marker.setIcon(makeArrowIcon(p.heading || 0));
|
||||
updateInfo(idx);
|
||||
}
|
||||
|
||||
function updateInfo(idx) {
|
||||
const p = points[idx];
|
||||
if (!p) return;
|
||||
const hdg = p.heading !== null ? `${p.heading.toFixed(1)}°` : 'N/A';
|
||||
document.getElementById('info').textContent =
|
||||
`[${idx+1}/${points.length}] ${p.t} | Lat: ${p.lat.toFixed(6)} Lon: ${p.lon.toFixed(6)} | Cap: ${hdg}`;
|
||||
}
|
||||
|
||||
loadData();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user