Files
seisee/index.html.final

2706 lines
118 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🌊 SeiSee Web — Seismic Viewer</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js">
// Format seconds as H:MM:SS or M:SS
function fmtTime(s) {
s = Math.round(s);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return h + ':' + String(m).padStart(2,'0') + ':' + String(sec).padStart(2,'0');
return m + ':' + String(sec).padStart(2,'0');
}
let gatherMaxDuration = 8000;
let gatherSliderDebounce = null;
// Called when slider is dragged — live update
function onGatherSliderInput(val) {
val = parseInt(val);
document.getElementById('gatherStart').value = val;
document.getElementById('gatherSliderLabel').textContent = fmtTime(val);
// Debounce: reload after 300ms of no sliding
clearTimeout(gatherSliderDebounce);
gatherSliderDebounce = setTimeout(() => { if (gatherSelectedFiles.size > 0) loadGather(); }, 300);
}
// Called when number inputs change
function onGatherParamChange() {
const start = parseInt(document.getElementById('gatherStart').value) || 0;
document.getElementById('gatherTimeSlider').value = start;
document.getElementById('gatherSliderLabel').textContent = fmtTime(start);
if (gatherSelectedFiles.size > 0) loadGather();
}
// Update slider max when file info is known
function updateGatherSliderMax(maxDur) {
gatherMaxDuration = Math.floor(maxDur);
const slider = document.getElementById('gatherTimeSlider');
slider.max = gatherMaxDuration;
document.getElementById('gatherSliderMax').textContent = fmtTime(gatherMaxDuration);
const durEl = document.getElementById('gatherDuration');
durEl.max = gatherMaxDuration;
}
</script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js">
// Format seconds as H:MM:SS or M:SS
function fmtTime(s) {
s = Math.round(s);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return h + ':' + String(m).padStart(2,'0') + ':' + String(sec).padStart(2,'0');
return m + ':' + String(sec).padStart(2,'0');
}
let gatherMaxDuration = 8000;
let gatherSliderDebounce = null;
// Called when slider is dragged — live update
function onGatherSliderInput(val) {
val = parseInt(val);
document.getElementById('gatherStart').value = val;
document.getElementById('gatherSliderLabel').textContent = fmtTime(val);
// Debounce: reload after 300ms of no sliding
clearTimeout(gatherSliderDebounce);
gatherSliderDebounce = setTimeout(() => { if (gatherSelectedFiles.size > 0) loadGather(); }, 300);
}
// Called when number inputs change
function onGatherParamChange() {
const start = parseInt(document.getElementById('gatherStart').value) || 0;
document.getElementById('gatherTimeSlider').value = start;
document.getElementById('gatherSliderLabel').textContent = fmtTime(start);
if (gatherSelectedFiles.size > 0) loadGather();
}
// Update slider max when file info is known
function updateGatherSliderMax(maxDur) {
gatherMaxDuration = Math.floor(maxDur);
const slider = document.getElementById('gatherTimeSlider');
slider.max = gatherMaxDuration;
document.getElementById('gatherSliderMax').textContent = fmtTime(gatherMaxDuration);
const durEl = document.getElementById('gatherDuration');
durEl.max = gatherMaxDuration;
}
</script>
<style>
:root{--bg:#1a1a2e;--panel:#16213e;--header:#0f3460;--accent:#00d4ff;--accent2:#e94560;--text:#eee;--muted:#8899aa;--border:#2a2a4a;--green:#4ade80;--yellow:#fde047;--hover:#1a3a6a}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:flex;flex-direction:column}
.hdr{background:var(--header);padding:6px 16px;display:flex;align-items:center;gap:16px;border-bottom:1px solid var(--border);flex-shrink:0}
.hdr h1{font-size:1rem;color:var(--accent);white-space:nowrap}
.hdr .sep{width:1px;height:24px;background:var(--border)}
.toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex:1}
.toolbar label{font-size:.7rem;color:var(--muted);text-transform:uppercase}
.toolbar select,.toolbar input[type=number]{background:var(--bg);border:1px solid var(--border);color:var(--text);padding:3px 6px;border-radius:3px;font-size:.8rem}
.toolbar input[type=number]{width:65px}
.toolbar select{min-width:80px}
.tbtn{background:var(--accent);color:#000;border:none;padding:4px 10px;border-radius:3px;cursor:pointer;font-weight:600;font-size:.75rem}
.tbtn:hover{opacity:.85}
.tbtn.active{background:var(--accent2);color:#fff}
.tgroup{display:flex;align-items:center;gap:4px;padding:0 6px;border-left:1px solid var(--border)}
.main{display:flex;flex:1;overflow:hidden}
.sidebar{width:260px;min-width:200px;max-width:400px;background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
.sidebar .stitle{padding:8px 12px;font-size:.75rem;color:var(--muted);text-transform:uppercase;border-bottom:1px solid var(--border);display:flex;justify-content:space-between}
.sidebar .search{width:100%;padding:6px 10px;background:var(--bg);border:none;border-bottom:1px solid var(--border);color:var(--text);font-size:.8rem}
.sidebar .search:focus{outline:none;background:#0d1117}
.flist{flex:1;overflow-y:auto;min-height:0}
.fitem{padding:5px 12px;cursor:pointer;border-bottom:1px solid var(--border);font-size:.8rem}
.fitem:hover{background:var(--hover)}
.fitem.active{background:var(--hover);border-left:3px solid var(--accent)}
.fitem .fhead{display:flex;justify-content:space-between;align-items:center;margin-bottom:2px}
.fitem .fn{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
.fitem .ft{font-size:.65rem;padding:1px 5px;border-radius:3px;font-weight:700;flex-shrink:0;margin-left:6px}
.fitem .finfo{font-size:.65rem;color:var(--muted)}
.ft-h5{background:#1e3a8a;color:#60a5fa}
.ft-segy{background:#166534;color:var(--green)}
.content{flex:1;display:flex;flex-direction:column;overflow:hidden}
.tabs{display:flex;background:var(--panel);border-bottom:1px solid var(--border);flex-shrink:0}
.tab{padding:6px 16px;cursor:pointer;font-size:.8rem;color:var(--muted);border-bottom:2px solid transparent}
.tab:hover{color:var(--text)}
.tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.view{flex:1;overflow:hidden;display:none;position:relative}
.view.active{display:flex;flex-direction:column}
#sectionView{position:relative}
#heatmapContainer{flex:1;min-height:0}
#wiggleCanvas{position:absolute;top:0;left:0;pointer-events:none}
.htable-wrap{flex:1;overflow:auto;padding:8px}
.htable{width:100%;border-collapse:collapse;font-size:.75rem}
.htable th{background:var(--header);padding:4px 8px;text-align:left;position:sticky;top:0;z-index:1;color:var(--accent)}
.htable td{padding:3px 8px;border-bottom:1px solid var(--border)}
.htable tr:hover td{background:var(--hover)}
.infopanel{background:var(--panel);border-top:1px solid var(--border);padding:4px 12px;font-size:.7rem;color:var(--muted);display:flex;gap:16px;flex-shrink:0}
.infopanel span{white-space:nowrap}
.empty{display:flex;align-items:center;justify-content:center;height:100%;color:var(--muted);font-size:1rem}
.resize-handle{width:4px;cursor:col-resize;background:transparent;flex-shrink:0}
.resize-handle:hover{background:var(--accent)}
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:var(--bg)}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:var(--muted)}
.ch-section{border-top:1px solid var(--border);padding:8px 12px;flex-shrink:0;max-height:40vh;overflow-y:auto}
.ch-section .stitle{padding:0 0 6px 0;border:none}
.ch-btn{display:block;width:100%;text-align:left;padding:3px 8px;background:none;border:1px solid var(--border);color:var(--text);cursor:pointer;font-size:.75rem;border-radius:2px;margin-bottom:2px}
.ch-btn:hover{border-color:var(--accent)}
.ch-btn.active{background:var(--hover);border-color:var(--accent);color:var(--accent)}
.meta-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px;padding:16px;overflow:auto}
.meta-card{background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:12px}
.meta-card h3{color:var(--accent);font-size:.9rem;margin-bottom:8px;border-bottom:1px solid var(--border);padding-bottom:4px}
.meta-row{display:flex;justify-content:space-between;padding:4px 0;font-size:.75rem;border-bottom:1px solid rgba(255,255,255,0.05)}
.meta-row .label{color:var(--muted);font-weight:600}
.meta-row .value{color:var(--text);text-align:right;max-width:60%;overflow:hidden;text-overflow:ellipsis}
#mapContainer{flex:1;position:relative}
#map{width:100%;height:100%}
.map-controls{position:absolute;top:10px;right:10px;z-index:1000;background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:8px}
.map-controls button{background:var(--accent);color:#000;border:none;padding:4px 8px;border-radius:3px;cursor:pointer;font-size:.75rem;margin:2px}
.map-controls button:hover{opacity:.85}
.extract-panel{padding:16px;overflow:auto}
.extract-form{background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:16px;max-width:600px}
.extract-form .form-row{margin-bottom:12px}
.extract-form label{display:block;font-size:.75rem;color:var(--muted);margin-bottom:4px;text-transform:uppercase}
.extract-form input,.extract-form select{width:100%;padding:6px 10px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:3px;font-size:.8rem}
.extract-form button{background:var(--green);color:#000;border:none;padding:8px 16px;border-radius:3px;cursor:pointer;font-weight:600;font-size:.85rem;width:100%}
.extract-form button:hover{opacity:.85}
.extract-info{margin-top:12px;padding:8px;background:rgba(0,212,255,0.1);border-left:3px solid var(--accent);font-size:.75rem;color:var(--text)}
.pipeline-panel{padding:20px;overflow:auto;display:flex;flex-direction:column;gap:20px}
.pipeline-diagram{display:flex;align-items:stretch;justify-content:space-between;background:var(--panel);padding:20px;border-radius:8px;border:1px solid var(--border);flex-wrap:wrap;gap:16px}
.p-step{flex:1;min-width:240px;background:var(--bg);padding:16px;border-radius:6px;border:1px solid var(--border);display:flex;flex-direction:column;position:relative}
.p-step h4{color:var(--accent);font-size:1.1rem;margin-bottom:6px}
.p-step p{font-size:.8rem;color:var(--muted);margin-bottom:8px;line-height:1.4}
.p-step .count{font-size:1.5rem;font-weight:bold;color:var(--text);margin:8px 0}
.p-step .tech{font-size:.7rem;background:var(--hover);padding:4px 8px;border-radius:3px;display:inline-block;margin-top:auto;margin-bottom:8px}
.p-step .formulas{margin-top:8px;padding-top:8px;border-top:1px solid var(--border);font-size:.7rem;line-height:1.6}
.p-step .formulas div{margin:3px 0;color:var(--muted)}
.p-step .formulas code{background:rgba(0,212,255,0.1);padding:2px 5px;border-radius:2px;color:var(--accent);font-family:monospace;font-size:.68rem}
.p-arrow{color:var(--muted);font-size:2rem;display:flex;align-items:center}
.p-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px}
.stat-card{background:var(--panel);padding:16px;border-radius:8px;border:1px solid var(--border)}
.stat-card h5{color:var(--muted);font-size:.75rem;text-transform:uppercase;margin-bottom:8px}
.stat-card .val{font-size:1.5rem;font-weight:bold;color:var(--accent)}
.progress-bar{height:6px;background:var(--bg);border-radius:3px;margin-top:8px;overflow:hidden}
.progress-fill{height:100%;background:var(--green);width:0%}
.geosup-panel{padding:20px;overflow:auto;display:flex;flex-direction:column;gap:20px}
.geosup-section{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:16px}
.geosup-section h3{color:var(--accent);font-size:1rem;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.doc-list{display:flex;flex-direction:column;gap:8px}
.doc-item{display:flex;align-items:center;gap:12px;padding:10px;background:var(--bg);border-radius:4px;cursor:pointer;transition:background 0.2s}
.doc-item:hover{background:var(--hover)}
.doc-item .icon{font-size:1.5rem}
.doc-item .info{flex:1}
.doc-item .name{color:var(--text);font-weight:500;font-size:.85rem}
.doc-item .meta{color:var(--muted);font-size:.7rem;margin-top:2px}
.doc-item .badge{background:var(--accent);color:#000;padding:2px 6px;border-radius:3px;font-size:.65rem;font-weight:700}
.doc-item .badge.gundalf{background:var(--accent2);color:#fff}
.geosup-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
.geosup-stat{background:var(--bg);padding:12px;border-radius:4px;text-align:center}
.geosup-stat .val{font-size:1.5rem;font-weight:bold;color:var(--accent)}
.geosup-stat .label{font-size:.7rem;color:var(--muted);margin-top:4px}
.map-layers{position:absolute;top:10px;left:10px;z-index:1000;background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:8px;max-width:200px}
.map-layers h5{font-size:.7rem;color:var(--muted);margin-bottom:6px;text-transform:uppercase}
.layer-toggle{display:flex;align-items:center;gap:6px;padding:3px 0;font-size:.75rem;cursor:pointer}
.layer-toggle input{cursor:pointer}
.layer-toggle .dot{width:10px;height:10px;border-radius:50%;display:inline-block}
.dot-source{background:#3b82f6}
.dot-receiver{background:#ef4444}
.dot-deployment{background:#22c55e}
.dot-file{background:#fbbf24}
.load-info{background:var(--panel);border:1px solid var(--accent);border-radius:4px;padding:8px 12px;margin-bottom:8px;font-size:.75rem;line-height:1.6}
.load-info .label{color:var(--muted);font-weight:600;margin-right:4px}
.load-info .value{color:var(--accent)}
/* Gather view */
.gather-panel{display:flex;flex-direction:column;flex:1;overflow:hidden}
.gather-controls{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--border);flex-wrap:wrap;flex-shrink:0}
.gather-controls label{font-size:.7rem;color:var(--muted);text-transform:uppercase}
.gather-controls select,.gather-controls input[type=number],.gather-controls input[type=range]{background:var(--bg);border:1px solid var(--border);color:var(--text);padding:3px 6px;border-radius:3px;font-size:.8rem}
.gather-controls input[type=number]{width:65px}
.gather-controls input[type=range]{width:100px;vertical-align:middle}
.gather-canvas-wrap{flex:1;overflow:auto;position:relative;min-height:0}
#gatherCanvas{display:block}
.gather-stats{padding:4px 12px;font-size:.7rem;color:var(--muted);background:var(--panel);border-top:1px solid var(--border);flex-shrink:0}
.gather-cb{margin-right:6px;cursor:pointer;accent-color:var(--accent)}
.gather-line-select{padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--border);display:flex;gap:6px;align-items:center;flex-shrink:0}
.gather-line-select label{font-size:.7rem;color:var(--muted)}
.gather-line-select select{background:var(--bg);border:1px solid var(--border);color:var(--text);padding:3px 6px;border-radius:3px;font-size:.8rem}
.gather-line-select button{background:var(--accent);color:#000;border:none;padding:3px 8px;border-radius:3px;cursor:pointer;font-size:.75rem;font-weight:600}
/* Coverage view */
.coverage-panel{display:flex;flex-direction:column;flex:1;overflow:hidden}
.coverage-stats{display:flex;gap:16px;padding:12px 16px;background:var(--panel);border-bottom:1px solid var(--border);flex-shrink:0;flex-wrap:wrap;align-items:center}
.coverage-stat{text-align:center}
.coverage-stat .val{font-size:1.3rem;font-weight:bold;color:var(--accent)}
.coverage-stat .label{font-size:.65rem;color:var(--muted)}
.coverage-legend{display:flex;gap:10px;margin-left:auto;flex-wrap:wrap}
.coverage-legend-item{display:flex;align-items:center;gap:4px;font-size:.7rem;color:var(--muted)}
.coverage-legend-item .swatch{width:12px;height:12px;border-radius:2px}
.coverage-chart-wrap{flex:1;overflow:auto;padding:8px;min-height:0}
.coverage-chart-wrap svg{display:block}
</style>
</head>
<body>
<div class="hdr">
<h1>🌊 SeiSee Web</h1>
<div class="sep"></div>
<div class="toolbar">
<div class="tgroup" style="border:none">
<label>View:</label>
<button class="tbtn active" id="btnDensity" onclick="setView('density')">Density</button>
<button class="tbtn" id="btnWiggle" onclick="setView('wiggle')">Wiggle</button>
<button class="tbtn" id="btnBoth" onclick="setView('both')">Both</button>
</div>
<div class="tgroup">
<label>Colormap:</label>
<select id="selCmap" onchange="renderSection()">
<option value="RdBu_r">Seismic (RdBu)</option>
<option value="Greys">Grayscale</option>
<option value="Viridis">Viridis</option>
<option value="Plasma">Plasma</option>
<option value="Hot">Hot</option>
<option value="Picnic">Picnic</option>
</select>
</div>
<div class="tgroup">
<label>Gain:</label>
<input type="number" id="inGain" value="1" min="0.1" max="100" step="0.5" onchange="renderSection()">
</div>
<div class="tgroup">
<label>Clip %:</label>
<input type="number" id="inClip" value="99" min="50" max="100" step="1" onchange="renderSection()">
</div>
<div class="tgroup">
<label>Début (s):</label>
<input type="number" id="inTimeStart" value="0" min="0" step="10" onchange="loadSection()">
<span style="color:var(--muted)">·</span>
<label>Durée (s):</label>
<input type="number" id="inDuration" value="60" min="1" max="3600" step="10" onchange="loadSection()">
</div>
<div class="tgroup">
<label>Résolution:</label>
<input type="number" id="inResolution" value="200" min="50" max="1000" step="50">
<span style="font-size:.65rem;color:var(--muted);margin-left:4px">colonnes</span>
</div>
<div class="tgroup">
<label>Norm:</label>
<select id="selNorm" onchange="renderSection()">
<option value="global">Globale</option>
<option value="trace">Par colonne</option>
</select>
</div>
<div class="tgroup">
<label>Canal:</label>
<select id="selCanal" onchange="selectChannel(this.value)">
<option value="calibrated_data/channel_1">channel_1</option>
<option value="calibrated_data/channel_2">channel_2</option>
<option value="calibrated_data/channel_3">channel_3</option>
<option value="calibrated_data/channel_4">channel_4</option>
</select>
</div>
<div class="tgroup">
<button class="tbtn" onclick="loadSection()" style="background:var(--green);color:#000">▶ Load</button>
</div>
</div>
</div>
<div class="main">
<div class="sidebar" id="sidebar">
<div class="stitle"><span>Files</span><span id="fileCount">0</span></div>
<input type="text" class="search" id="searchInput" placeholder="Search..." oninput="filterFiles()">
<div class="flist" id="fileList"></div>
<div class="ch-section" id="channelSection" style="display:none">
<div class="stitle">Channel</div>
<div id="channelList"></div>
</div>
</div>
<div class="resize-handle" id="resizeHandle"></div>
<div class="content">
<div class="tabs">
<div class="tab active" onclick="switchTab('section')">Section</div>
<div class="tab" onclick="switchTab('metadata')">Metadata</div>
<div class="tab" onclick="switchTab('map')">Map</div>
<div class="tab" onclick="switchTab('extract')">Extract</div>
<div class="tab" onclick="switchTab('pipeline')">Pipeline</div>
<div class="tab" onclick="switchTab('geosup')">Geosup</div>
<div class="tab" onclick="switchTab('headers')">Headers</div>
<div class="tab" onclick="switchTab('waveform')">Waveform</div>
<div class="tab" onclick="switchTab('gather')">Gather</div>
<div class="tab" onclick="switchTab('coverage')">Coverage</div>
</div>
<div class="view active" id="sectionView">
<div id="loadInfoPanel" style="display:none" class="load-info">
<span class="label">Loaded:</span>
<span class="value" id="loadedTracesInfo">—</span>
</div>
<div id="heatmapContainer"></div>
<canvas id="wiggleCanvas"></canvas>
</div>
<div class="view" id="metadataView">
<div class="meta-grid" id="metadataContent">
<div class="empty">Select a file to view metadata</div>
</div>
</div>
<div class="view" id="mapView">
<div id="mapContainer">
<div id="map"></div>
<div class="map-layers" id="mapLayers">
<h5>Geosup Layers</h5>
<label class="layer-toggle"><input type="checkbox" id="layerSources" onchange="toggleLayer('sources')"><span class="dot dot-source"></span> Sources</label>
<label class="layer-toggle"><input type="checkbox" id="layerReceivers" onchange="toggleLayer('receivers')"><span class="dot dot-receiver"></span> Receivers</label>
<label class="layer-toggle"><input type="checkbox" id="layerDeployments" onchange="toggleLayer('deployments')"><span class="dot dot-deployment"></span> Deployments</label>
<label class="layer-toggle"><input type="checkbox" id="layerAslaid" onchange="toggleLayer('aslaid')"><span class="dot" style="background:#f59e0b"></span> Aslaid (Real)</label>
<label class="layer-toggle"><input type="checkbox" id="layerRealShots" onchange="toggleLayer('realshots')"><span class="dot" style="background:#ff6600"></span> Real Shots</label>
<label class="layer-toggle"><input type="checkbox" id="layerFiles" checked onchange="toggleLayer('files')"><span class="dot dot-file"></span> Files</label>
</div>
<div class="map-controls">
<div style="margin-bottom:4px;font-size:.7rem;color:var(--muted)">Edit Location</div>
<button onclick="enableLocationEdit()">📍 Set Location</button>
<button onclick="loadGeosupLayers()" style="margin-top:4px">🔄 Load Geosup</button>
</div>
</div>
</div>
<div class="view" id="extractView">
<div class="extract-panel">
<div class="extract-form">
<h3 style="color:var(--accent);margin-bottom:12px">Extract Segment</h3>
<div class="form-row">
<label>Channel</label>
<select id="extractChannel"></select>
</div>
<div class="form-row">
<label>Start Time (seconds)</label>
<input type="number" id="extractStart" value="0" min="0" step="60">
</div>
<div class="form-row">
<label>Duration (seconds)</label>
<input type="number" id="extractDuration" value="1200" min="1" step="60">
<div style="font-size:.65rem;color:var(--muted);margin-top:2px">Default: 1200s (20 minutes)</div>
</div>
<div class="form-row">
<label>Format</label>
<select id="extractFormat">
<option value="csv">CSV</option>
<option value="h5">HDF5 (.h5)</option>
</select>
</div>
<button onclick="downloadExtract()">⬇️ Download Extract</button>
<div class="extract-info" id="extractInfo" style="display:none"></div>
</div>
</div>
</div>
<div class="view" id="pipelineView">
<div class="pipeline-panel">
<h2 style="color:var(--accent);font-size:1.2rem;border-bottom:1px solid var(--border);padding-bottom:10px">Processing Chain — RAW → H5</h2>
<div class="pipeline-diagram">
<div class="p-step">
<h4>1. RAW</h4>
<p>Données brutes Manta (format propriétaire)</p>
<div class="count" id="countRaw">--</div>
<div class="tech">Format Propriétaire</div>
<div class="formulas">
<div><strong>Source:</strong> Hydrophones + Géophones Manta</div>
<div><strong>Résolution ADC:</strong> 24-bit, ±2.5V</div>
</div>
</div>
<div class="p-arrow">➔</div>
<div class="p-step">
<h4>2. SEGY</h4>
<p>Conversion intermédiaire via mantasegy (Box64/ARM)</p>
<div class="count" id="countSegy">--</div>
<div class="tech">mantasegy (Box64/ARM)</div>
<div class="formulas">
<div><strong>Format:</strong> SEG-Y Rev.1</div>
<div><strong>Conversion:</strong> Binaire Manta → SEG-Y</div>
</div>
</div>
<div class="p-arrow">➔</div>
<div class="p-step">
<h4>3. Normalisation</h4>
<p>Conversion ADC → valeurs physiques calibrées</p>
<div class="tech">Python + Calibration</div>
<div class="formulas">
<div><strong>Géophones (SM-24):</strong></div>
<div><code>v(m/s) = (ADC / gain) × 28.8 V/(m/s)</code></div>
<div style="margin-top:6px"><strong>Hydrophones:</strong></div>
<div><code>P(µPa) = (ADC / gain) × sens(µPa/V)</code></div>
<div style="margin-top:6px"><strong>ADC → Voltage:</strong></div>
<div><code>V = ADC × (5.0 / 2²⁴)</code></div>
<div style="margin-top:6px"><strong>Normalisation trace:</strong></div>
<div><code>(trace - mean) / std</code></div>
</div>
</div>
<div class="p-arrow">➔</div>
<div class="p-step">
<h4>4. HDF5</h4>
<p>Export HDF5 avec Python + calibration</p>
<div class="count" id="countH5">--</div>
<div class="tech">Python + h5py</div>
<div class="formulas">
<div><strong>Format:</strong> HDF5 (.h5)</div>
<div><strong>Groupes:</strong> calibrated_data, raw_data, metadata</div>
<div><strong>Compression:</strong> gzip</div>
</div>
</div>
</div>
<div class="p-stats">
<div class="stat-card">
<h5>Espace Disque (USB)</h5>
<div class="val" id="diskUsage">-- %</div>
<div class="progress-bar"><div class="progress-fill" id="diskBar" style="width:0%"></div></div>
<div style="font-size:.75rem;color:var(--muted);margin-top:4px" id="diskDetail">-- GB utilisés sur -- GB</div>
</div>
<div class="stat-card">
<h5>Status Conversion</h5>
<div class="val" id="convertStatus" style="font-size:1rem">Inactif</div>
<div style="font-size:.75rem;color:var(--muted);margin-top:4px" id="convertDetail">Aucune tâche en cours</div>
</div>
<div class="stat-card">
<h5>Complétion H5</h5>
<div class="val" id="completionPct">-- %</div>
<div class="progress-bar"><div class="progress-fill" id="completionBar" style="width:0%"></div></div>
<div style="font-size:.75rem;color:var(--muted);margin-top:4px" id="completionDetail">-- / 345 fichiers traités</div>
</div>
</div>
<button onclick="loadPipelineStatus()" style="width:140px;padding:8px;background:var(--panel);border:1px solid var(--border);color:var(--text);border-radius:4px;cursor:pointer">↻ Actualiser</button>
</div>
</div>
<div class="view" id="geosupView">
<div class="geosup-panel">
<h2 style="color:var(--accent);font-size:1.2rem;border-bottom:1px solid var(--border);padding-bottom:10px">📍 Geosup — Positions & Documentation</h2>
<div class="geosup-stats" id="geosupStats">
<div class="geosup-stat"><div class="val" id="statSources">--</div><div class="label">Sources</div></div>
<div class="geosup-stat"><div class="val" id="statReceivers">--</div><div class="label">Receivers</div></div>
<div class="geosup-stat"><div class="val" id="statDeployments">--</div><div class="label">Deployments</div></div>
<div class="geosup-stat"><div class="val" id="statDocs">--</div><div class="label">Documents</div></div>
</div>
<div class="geosup-section">
<h3>📚 Documentation</h3>
<div class="doc-list" id="docList">
<div class="empty">Chargement...</div>
</div>
</div>
<div class="geosup-section">
<h3>🗺️ Position Coverage</h3>
<p style="font-size:.8rem;color:var(--muted);margin-bottom:8px">Use the <strong>Map</strong> tab to visualize source, receiver, and deployment positions. Toggle layers using the controls.</p>
<button onclick="switchTab('map');setTimeout(loadGeosupLayers,500)" class="tbtn" style="background:var(--green)">Open Map with Positions →</button>
</div>
</div>
</div>
<div class="view" id="headersView">
<div class="htable-wrap" id="headersContent">
<div class="empty">Select a file to view headers</div>
</div>
</div>
<div class="view" id="waveformView">
<div id="waveformControls" style="display:flex;align-items:center;gap:16px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--border);flex-wrap:wrap">
<span style="font-size:.7rem;color:var(--muted);text-transform:uppercase">Channels:</span>
<div id="waveformChannelChecks" style="display:flex;gap:8px;flex-wrap:wrap"></div>
<span style="color:var(--border)">|</span>
<label style="font-size:.7rem;color:var(--muted);text-transform:uppercase">Start (s):</label>
<input type="number" id="wfStart" value="0" min="0" step="10" style="width:70px;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:2px 5px;border-radius:3px;font-size:.8rem">
<label style="font-size:.7rem;color:var(--muted);text-transform:uppercase">Duration (s):</label>
<input type="number" id="wfDuration" value="" min="1" step="10" style="width:70px;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:2px 5px;border-radius:3px;font-size:.8rem" placeholder="all">
<button class="tbtn" onclick="loadWaveform()" style="background:var(--green);color:#000;font-size:.75rem;padding:3px 10px">▶ Load</button>
</div>
<div id="waveformChart" style="flex:1"></div>
</div>
<div class="view" id="gatherView">
<div class="gather-panel">
<div class="gather-line-select" id="gatherLineSelect">
<label>Select Line:</label>
<select id="gatherLineDropdown" onchange="selectGatherLine(this.value)">
<option value="">-- Choose --</option>
</select>
<button onclick="selectAllGatherFiles()">Select All</button>
<button onclick="clearGatherFiles()" style="background:var(--accent2);color:#fff">Clear</button>
<span id="gatherSelCount" style="font-size:.75rem;color:var(--accent);margin-left:8px">0 selected</span>
</div>
<div class="gather-controls">
<label>Sort:</label>
<select id="gatherSort" onchange="renderGather()">
<option value="board_id">Board ID</option>
<option value="line_point">Line + Point</option>
<option value="distance">Distance</option>
</select>
<label>Norm:</label>
<select id="gatherNorm" onchange="renderGather()">
<option value="global">Global</option>
<option value="trace" selected>Per-trace</option>
</select>
<label>Gain:</label>
<input type="number" id="gatherGain" value="5" min="0.1" max="100" step="0.5" onchange="renderGather()">
<label>Spacing:</label>
<input type="range" id="gatherSpacing" min="20" max="200" value="60" oninput="renderGather()">
<label>Mode:</label>
<select id="gatherMode" onchange="renderGather()">
<option value="wiggle">Wiggle</option>
<option value="density">Density</option>
</select>
<label>Channel:</label>
<select id="gatherChannel">
<option value="calibrated_data/channel_1">channel_1</option>
<option value="calibrated_data/channel_2">channel_2</option>
<option value="calibrated_data/channel_3">channel_3</option>
<option value="calibrated_data/channel_4">channel_4</option>
</select>
<label>START:</label>
<input type="number" id="gatherStart" value="0" min="0" step="10" style="width:70px" onchange="onGatherParamChange()">
<label>DURATION:</label>
<input type="number" id="gatherDuration" value="60" min="1" step="10" style="width:70px" onchange="onGatherParamChange()">
<div style="display:flex;align-items:center;gap:6px;flex:1;min-width:300px">
<span style="font-size:.7rem;color:var(--accent);font-weight:600;min-width:55px" id="gatherSliderLabel">0:00</span>
<input type="range" id="gatherTimeSlider" min="0" max="8000" value="0" step="1"
style="flex:1;height:24px;accent-color:var(--accent);cursor:pointer"
oninput="onGatherSliderInput(this.value)">
<span style="font-size:.7rem;color:var(--muted);min-width:55px;text-align:right" id="gatherSliderMax">2:13:20</span>
</div>
<button class="tbtn" onclick="loadGather()" style="background:var(--green);color:#000">Load Gather</button>
<button class="tbtn" onclick="extractGather()" style="background:#f59e0b;color:#000;margin-left:4px" title="Export full resolution data (all channels, no decimation)">📥 Extract</button>
</div>
<div class="gather-canvas-wrap" id="gatherCanvasWrap" style="flex:1;overflow:auto;position:relative;min-height:0;border:1px solid var(--border);border-radius:4px;margin:4px 8px">
<canvas id="gatherCanvas" style="display:block;width:100%"></canvas>
<div class="empty" id="gatherEmpty">Select files using checkboxes in the file list, then click "Load Gather"</div>
</div>
<div class="gather-stats" id="gatherStats" style="padding:4px 12px;font-size:.7rem;color:var(--muted)"></div>
</div>
</div>
<div class="view" id="coverageView">
<div class="coverage-panel">
<div class="coverage-stats" id="coverageStats">
<div class="coverage-stat"><div class="val" id="covNodes">--</div><div class="label">Nodes</div></div>
<div class="coverage-stat"><div class="val" id="covFiles">--</div><div class="label">Files</div></div>
<div class="coverage-stat"><div class="val" id="covHours">--</div><div class="label">Total Hours</div></div>
<div class="coverage-stat"><div class="val" id="covLines">--</div><div class="label">Lines</div></div>
<div class="coverage-legend" id="coverageLegend"></div>
<button class="tbtn" onclick="loadCoverage()" style="background:var(--green);color:#000">Refresh</button>
</div>
<div class="coverage-chart-wrap" id="coverageChartWrap">
<div class="empty" id="coverageEmpty">Loading coverage data...</div>
</div>
</div>
</div>
<div class="infopanel" id="infopanel">
<span>Ready</span>
</div>
</div>
</div>
<script>
const API = '/seismic/api';
let files = [], selectedFile = null, selectedChannel = 'calibrated_data/channel_1';
let sectionData = null;
let currentView = 'density';
let currentTab = 'section';
let currentSampleRate = 500; // Hz, updated when file is selected
let currentTotalDuration = 0; // seconds, total duration of file
let map = null;
let locations = {};
let currentMetadata = null;
let fileInfoCache = {};
let darfNodes = {};
let gatherSelectedFiles = new Set();
let gatherData = null;
let coverageData = null;
function fmtFileName(fn) {
var m = fn.match(/auto_(\d+)_(\d{2})(\d{2})(\d{2})_b(\d+)/);
if (m) {
var day = m[1], hh = m[2], mm = m[3], bid = m[5];
var node = darfNodes && darfNodes[bid];
var label = 'b' + bid;
if (node && node.line) label += ' L' + node.line;
label += ' (D' + day + ' ' + hh + ':' + mm + ')';
return label;
}
return fn.replace('.h5','').substring(0,25);
}
async function init() {
const r = await fetch(API + '/files');
const d = await r.json();
files = d.files || [];
document.getElementById('fileCount').textContent = files.length;
renderFiles(files);
setupResize();
try {
const lr = await fetch(API + '/locations');
locations = await lr.json();
} catch (e) {
console.error('Failed to load locations:', e);
}
// Load DARF nodes for gather/coverage
try {
const dr = await fetch(API.replace('/api','') + '/darf_nodes.json');
darfNodes = await dr.json();
populateGatherLineDropdown();
} catch (e) {
console.error('Failed to load DARF nodes:', e);
}
}
let sidebarGroupBy = 'line'; // 'line' or 'flat'
let collapsedLines = {};
function getNodeInfo(fname) {
var m = fname.match(/auto_(\d+)_(\d{2})(\d{2})(\d{2})_b(\d+)/);
if (!m) return null;
var bid = m[5];
var node = darfNodes && darfNodes[bid];
return { day: m[1], hh: m[2], mm: m[3], bid: bid, node: node,
line: node ? node.line : 0, point: node ? node.point : 0 };
}
function renderFiles(list) {
const showCb = currentTab === 'gather';
if (sidebarGroupBy === 'flat' || !darfNodes || Object.keys(darfNodes).length === 0) {
renderFilesFlat(list, showCb);
return;
}
// Group by line
var groups = {};
var noLine = [];
list.forEach(function(f) {
var ni = getNodeInfo(f.name);
if (ni && ni.line) {
if (!groups[ni.line]) groups[ni.line] = [];
groups[ni.line].push({ f: f, ni: ni });
} else {
noLine.push(f);
}
});
// Sort lines
var sortedLines = Object.keys(groups).map(Number).sort(function(a,b){return a-b;});
var html = '';
// Line filter buttons
html += '<div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:6px;padding:0 4px">';
html += '<button class="lbtn' + (sidebarGroupBy==='flat'?' active':'') + '" onclick="sidebarGroupBy=\'flat\';filterFiles()" style="font-size:10px;padding:2px 6px;background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:3px;cursor:pointer">Flat</button>';
html += '<button class="lbtn' + (sidebarGroupBy==='line'?' active':'') + '" onclick="sidebarGroupBy=\'line\';filterFiles()" style="font-size:10px;padding:2px 6px;background:var(--accent);border:none;color:#fff;border-radius:3px;cursor:pointer">By Line</button>';
html += '</div>';
sortedLines.forEach(function(line) {
var items = groups[line];
// Sort by point within line
items.sort(function(a,b){ return (a.ni.point||0) - (b.ni.point||0); });
var collapsed = collapsedLines[line];
var count = items.length;
html += '<div class="line-group">';
html += '<div class="line-header" onclick="collapsedLines['+line+']=!collapsedLines['+line+'];filterFiles()" style="padding:4px 8px;background:rgba(59,130,246,0.15);border-left:3px solid var(--accent);margin:2px 0;cursor:pointer;display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:600;color:var(--accent)">';
html += '<span>' + (collapsed?'▶':'▼') + ' Line ' + line + '</span>';
html += '<span style="background:var(--accent);color:#fff;border-radius:8px;padding:1px 6px;font-size:10px">' + count + '</span>';
html += '</div>';
if (!collapsed) {
items.forEach(function(item) {
html += renderFileItem(item.f, showCb, item.ni);
});
}
html += '</div>';
});
// Unassigned
if (noLine.length > 0) {
html += '<div class="line-group">';
html += '<div class="line-header" onclick="collapsedLines[0]=!collapsedLines[0];filterFiles()" style="padding:4px 8px;background:rgba(100,100,100,0.15);border-left:3px solid #666;margin:2px 0;cursor:pointer;display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:600;color:#999">';
html += '<span>' + (collapsedLines[0]?'▶':'▼') + ' Unassigned</span>';
html += '<span style="background:#666;color:#fff;border-radius:8px;padding:1px 6px;font-size:10px">' + noLine.length + '</span>';
html += '</div>';
if (!collapsedLines[0]) {
noLine.forEach(function(f) { html += renderFileItem(f, showCb, null); });
}
html += '</div>';
}
document.getElementById('fileList').innerHTML = html;
}
function renderFilesFlat(list, showCb) {
var html = '<div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:6px;padding:0 4px">';
html += '<button onclick="sidebarGroupBy=\'flat\';filterFiles()" style="font-size:10px;padding:2px 6px;background:var(--accent);border:none;color:#fff;border-radius:3px;cursor:pointer">Flat</button>';
html += '<button onclick="sidebarGroupBy=\'line\';filterFiles()" style="font-size:10px;padding:2px 6px;background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:3px;cursor:pointer">By Line</button>';
html += '</div>';
list.forEach(function(f) {
html += renderFileItem(f, showCb, getNodeInfo(f.name));
});
document.getElementById('fileList').innerHTML = html;
}
function renderFileItem(f, showCb, ni) {
const info = fileInfoCache[f.name] || {};
let infoText = '';
if (info.duration_human && info.num_channels && info.sample_rate_hz) {
infoText = '<div class="finfo">' + info.duration_human + ' · ' + info.num_channels + ' ch · ' + info.sample_rate_hz + ' Hz</div>';
}
const escapedName = f.name.replace(/'/g, "\\'");
const cb = showCb ? '<input type="checkbox" class="gather-cb" ' + (gatherSelectedFiles.has(f.name)?'checked':'') + ' onclick="event.stopPropagation();toggleGatherFile(\'' + escapedName + '\',this.checked)" style="margin-right:6px;cursor:pointer;accent-color:var(--accent)">' : '';
var label = fmtFileName(f.name);
if (ni && ni.point) label = 'P' + ni.point + ' — ' + label;
return '<div class="fitem' + (selectedFile===f.name?' active':'') + '" onclick="selectFile(\'' + escapedName + '\')">' +
'<div class="fhead">' + cb + '<span class="fn" title="' + f.name + '">' + label + '</span>' +
'<span class="ft ft-' + f.type + '">' + f.type.toUpperCase() + '</span></div>' +
infoText + '</div>';
}
function filterFiles() {
const q = document.getElementById('searchInput').value.toLowerCase();
var filtered = files.filter(function(f) {
if (q && !f.name.toLowerCase().includes(q) && !fmtFileName(f.name).toLowerCase().includes(q)) return false;
return true;
});
renderFiles(filtered);
}
async function selectFile(name) {
selectedFile = name;
filterFiles();
setInfo(`Loading ${name}...`);
const r = await fetch(`${API}/file/${encodeURIComponent(name)}/info`);
const info = await r.json();
fileInfoCache[name] = info;
const chSec = document.getElementById('channelSection');
if (info.type === 'h5' && info.datasets) {
chSec.style.display = 'block';
const cals = info.datasets.filter(d => d.path.includes('calibrated'));
const raws = info.datasets.filter(d => d.path.includes('raw'));
let html = '<div style="font-size:.65rem;color:var(--muted);margin-bottom:4px">Calibrated:</div>';
cals.forEach(d => {
html += `<button class="ch-btn${d.path===selectedChannel?' active':''}" onclick="selectChannel('${d.path}')">${d.path.split('/').pop()} <span style="color:var(--muted)">(${d.dtype})</span></button>`;
});
if (raws.length) {
html += '<div style="font-size:.65rem;color:var(--muted);margin:4px 0">Raw:</div>';
raws.forEach(d => {
html += `<button class="ch-btn${d.path===selectedChannel?' active':''}" onclick="selectChannel('${d.path}')">${d.path.split('/').pop()} <span style="color:var(--muted)">(${d.dtype})</span></button>`;
});
}
document.getElementById('channelList').innerHTML = html;
const extractCh = document.getElementById('extractChannel');
extractCh.innerHTML = '';
[...cals, ...raws].forEach(d => {
const opt = document.createElement('option');
opt.value = d.path;
opt.textContent = d.path;
extractCh.appendChild(opt);
});
// Populate waveform channel checkboxes
populateWaveformChannels(info);
// Populate toolbar Canal dropdown
const canalSel = document.getElementById('selCanal');
if (canalSel) {
canalSel.innerHTML = '';
cals.forEach(d => {
const opt = document.createElement('option');
opt.value = d.path;
opt.textContent = d.path.split('/').pop();
if (d.path === selectedChannel) opt.selected = true;
canalSel.appendChild(opt);
});
raws.forEach(d => {
const opt = document.createElement('option');
opt.value = d.path;
opt.textContent = d.path.split('/').pop() + ' (raw)';
if (d.path === selectedChannel) opt.selected = true;
canalSel.appendChild(opt);
});
}
// Update global sample rate and duration
currentSampleRate = info.sample_rate_hz || info.sample_rate || 500;
if (info.samples_per_channel) {
currentTotalDuration = info.samples_per_channel / currentSampleRate;
} else if (info.duration_sec) {
currentTotalDuration = parseFloat(info.duration_sec);
}
// Set default time window
const defaultDuration = Math.ceil(currentTotalDuration);
document.getElementById('inTimeStart').value = 0;
document.getElementById('inDuration').value = defaultDuration;
document.getElementById('inTimeStart').max = Math.max(0, currentTotalDuration - 1);
document.getElementById("inDuration").max = Math.ceil(currentTotalDuration);
if (info.duration_human) {
setInfo(`${name} — ${info.duration_human}, ${info.num_channels} channels @ ${info.sample_rate_hz} Hz`);
} else if (info.samples_per_channel) {
const durationMin = (currentTotalDuration / 60).toFixed(1);
setInfo(`${name} — ${durationMin} min (${info.samples_per_channel.toLocaleString()} samp), ${info.num_channels} channels @ ${currentSampleRate} Hz`);
}
} else {
chSec.style.display = 'none';
if (info.num_traces) {
setInfo(`${name} — ${info.num_traces} traces, ${info.samples_per_trace} samples/trace`);
currentSampleRate = 500; // Default for SEGY
currentTotalDuration = (info.num_traces * info.samples_per_trace) / currentSampleRate;
}
}
renderFiles(files.filter(f => f.name.toLowerCase().includes(document.getElementById('searchInput').value.toLowerCase())));
loadMetadata();
if (currentTab === 'section') loadSection();
if (currentTab === 'headers') loadHeaders();
if (currentTab === 'map') initMap();
}
function selectChannel(ch) {
selectedChannel = ch;
document.querySelectorAll('.ch-btn').forEach(el => {
el.classList.toggle('active', el.onclick.toString().includes(ch));
});
// Sync toolbar Canal dropdown
const canalSel = document.getElementById('selCanal');
if (canalSel) canalSel.value = ch;
if (currentTab === 'section') loadSection();
}
async function loadMetadata() {
if (!selectedFile) return;
const mc = document.getElementById('metadataContent');
mc.innerHTML = '<div class="empty">Loading metadata...</div>';
try {
const r = await fetch(`${API}/file/${encodeURIComponent(selectedFile)}/metadata`);
const data = await r.json();
currentMetadata = data;
if (data.error) {
mc.innerHTML = `<div class="empty" style="color:var(--accent2)">${data.error}</div>`;
return;
}
let html = '';
html += '<div class="meta-card"><h3>📄 File Information</h3>';
html += `<div class="meta-row"><span class="label">Filename</span><span class="value">${data.filename || '—'}</span></div>`;
html += `<div class="meta-row"><span class="label">Size</span><span class="value">${data.file_size_mb || '—'} MB</span></div>`;
html += `<div class="meta-row"><span class="label">Modified</span><span class="value">${data.modified ? new Date(data.modified).toLocaleString('fr') : '—'}</span></div>`;
html += '</div>';
html += '<div class="meta-card"><h3>⏱️ Recording Information</h3>';
html += `<div class="meta-row"><span class="label">Start Time</span><span class="value">${data.start_timestamp ? new Date(data.start_timestamp).toLocaleString('fr') : '—'}</span></div>`;
html += `<div class="meta-row"><span class="label">End Time</span><span class="value">${data.end_timestamp ? new Date(data.end_timestamp).toLocaleString('fr') : '—'}</span></div>`;
html += `<div class="meta-row"><span class="label">Duration</span><span class="value">${data.duration_human || '—'}</span></div>`;
html += `<div class="meta-row"><span class="label">Sample Rate</span><span class="value">${data.sample_rate_hz || data.sample_rate || '—'} Hz</span></div>`;
html += '</div>';
html += '<div class="meta-card"><h3>📊 Data Information</h3>';
html += `<div class="meta-row"><span class="label">Channels</span><span class="value">${data.n_channels || data.num_traces || '—'}</span></div>`;
// Calculate duration from samples and sample rate
const totalSamples = data.n_samples || data.total_samples || 0;
const sampleRate = data.sample_rate_hz || data.sample_rate || 1;
const durationSec = totalSamples / sampleRate;
const durationMin = (durationSec / 60).toFixed(1);
html += `<div class="meta-row"><span class="label">Durée totale</span><span class="value">${durationMin} min (${totalSamples.toLocaleString()} samp)</span></div>`;
html += `<div class="meta-row"><span class="label">Durée par canal</span><span class="value">${durationMin} min</span></div>`;
html += `<div class="meta-row"><span class="label">Source File</span><span class="value" title="${data.source_file || '—'}">${data.source_file || '—'}</span></div>`;
html += '</div>';
if (data.calibration) {
html += '<div class="meta-card"><h3>⚙️ Calibration</h3>';
for (const [k, v] of Object.entries(data.calibration)) {
html += `<div class="meta-row"><span class="label">${k.replace(/_/g, ' ')}</span><span class="value">${v}</span></div>`;
}
html += '</div>';
}
if (data.channels && data.channels.length) {
html += '<div class="meta-card" style="grid-column:1/-1"><h3>📡 Channels</h3>';
html += '<table class="htable"><tr><th>Channel</th><th>Sensor</th><th>Unit</th><th>Samples</th><th>Type</th></tr>';
data.channels.forEach(ch => {
html += `<tr>
<td>${ch.name}</td>
<td>${ch.sensor || '—'}</td>
<td>${ch.unit || '—'}</td>
<td>${(ch.samples || 0).toLocaleString()}</td>
<td>${ch.dtype}</td>
</tr>`;
});
html += '</table></div>';
}
mc.innerHTML = html;
} catch (e) {
mc.innerHTML = `<div class="empty" style="color:var(--accent2)">Error: ${e.message}</div>`;
}
}
function initMap() {
if (!map) {
setTimeout(() => {
map = L.map('map').setView([43.2965, 5.3698], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap'
}).addTo(map);
map.on('click', onMapClick);
setTimeout(function(){ loadGeosupLayers(); }, 300);
}, 100);
}
updateMapMarkers();
}
function updateMapMarkers() {
if (!map) return;
map.eachLayer(layer => {
if (layer instanceof L.Marker) {
map.removeLayer(layer);
}
});
for (const [filename, loc] of Object.entries(locations)) {
const marker = L.marker([loc.lat, loc.lon]).addTo(map);
marker.bindPopup(`<b>${filename}</b><br>Lat: ${loc.lat.toFixed(6)}<br>Lon: ${loc.lon.toFixed(6)}`);
if (filename === selectedFile) {
marker.openPopup();
map.setView([loc.lat, loc.lon], 12);
}
}
}
let editingLocation = false;
function enableLocationEdit() {
if (!selectedFile) {
alert('Please select a file first');
return;
}
editingLocation = true;
alert(`Click on the map to set location for ${selectedFile}`);
}
async function onMapClick(e) {
if (!editingLocation || !selectedFile) return;
const lat = e.latlng.lat;
const lon = e.latlng.lng;
try {
const r = await fetch(API + '/locations', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({filename: selectedFile, lat, lon})
});
const data = await r.json();
if (data.success) {
locations = data.locations;
updateMapMarkers();
setInfo(`Location set for ${selectedFile}: ${lat.toFixed(6)}, ${lon.toFixed(6)}`);
}
} catch (e) {
setInfo(`Error setting location: ${e.message}`);
}
editingLocation = false;
}
async function downloadExtract() {
if (!selectedFile) {
alert('Please select a file first');
return;
}
const channel = document.getElementById('extractChannel').value;
const start = parseFloat(document.getElementById('extractStart').value);
const duration = parseFloat(document.getElementById('extractDuration').value);
const format = document.getElementById('extractFormat').value;
const info = document.getElementById('extractInfo');
info.style.display = 'block';
info.innerHTML = `⏳ Extracting ${duration}s from ${selectedFile}...`;
try {
const url = `${API}/file/${encodeURIComponent(selectedFile)}/extract?start_sec=${start}&duration_sec=${duration}&channel=${encodeURIComponent(channel)}&format=${format}`;
const response = await fetch(url);
if (!response.ok) {
const err = await response.json();
info.innerHTML = `❌ Error: ${err.error || 'Unknown error'}`;
return;
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = `extract_${selectedFile.replace('.h5', '')}_${start}_${duration}s.${format}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(downloadUrl);
info.innerHTML = `✅ Downloaded ${(blob.size / 1024 / 1024).toFixed(2)} MB`;
} catch (e) {
info.innerHTML = `❌ Error: ${e.message}`;
}
}
async function loadPipelineStatus() {
try {
const r = await fetch(API + '/pipeline-status');
const d = await r.json();
if (d.error) return;
document.getElementById('countRaw').textContent = d.counts.raw;
document.getElementById('countSegy').textContent = d.counts.segy;
document.getElementById('countH5').textContent = d.counts.h5;
const usedPct = d.disk.percent;
document.getElementById('diskUsage').textContent = `${usedPct}%`;
document.getElementById('diskBar').style.width = `${usedPct}%`;
document.getElementById('diskDetail').textContent = `${d.disk.used_gb} GB utilisés sur ${d.disk.total_gb} GB`;
const total = d.counts.total_expected || 345;
const h5 = d.counts.h5;
const pct = Math.round((h5 / total) * 100);
document.getElementById('completionPct').textContent = `${pct}%`;
document.getElementById('completionBar').style.width = `${pct}%`;
document.getElementById('completionDetail').textContent = `${h5} / ${total} fichiers H5 prêts`;
if (d.progress) {
document.getElementById('convertStatus').textContent = 'En cours...';
document.getElementById('convertStatus').style.color = 'var(--yellow)';
document.getElementById('convertDetail').textContent = `Fichier: ${d.progress.current_file}`;
} else {
document.getElementById('convertStatus').textContent = 'Inactif';
document.getElementById('convertStatus').style.color = 'var(--muted)';
document.getElementById('convertDetail').textContent = 'Aucune tâche en cours';
}
} catch (e) {
console.error('Pipeline status error:', e);
}
}
async function loadSection() {
if (!selectedFile) return;
setInfo('Chargement...');
// Read time inputs
const timeStart = parseFloat(document.getElementById('inTimeStart').value);
const duration = parseFloat(document.getElementById('inDuration').value);
const resolution = parseInt(document.getElementById('inResolution').value);
// Convert time to sample indices
const totalSamples = Math.floor(currentTotalDuration * currentSampleRate);
const startSample = Math.floor(timeStart * currentSampleRate);
const endSample = Math.floor((timeStart + duration) * currentSampleRate);
const windowSamples = endSample - startSample;
// Calculate samples_per_trace to get desired resolution (number of columns)
const samplesPerTrace = Math.max(100, Math.floor(windowSamples / resolution));
// Calculate trace parameters
const traceStart = Math.floor(startSample / samplesPerTrace);
const traceEnd = Math.floor(endSample / samplesPerTrace);
const traceCount = Math.min(resolution, traceEnd - traceStart);
const params = new URLSearchParams({
trace_start: traceStart,
trace_count: traceCount,
samples_per_trace: samplesPerTrace,
channel: selectedChannel,
max_samples: 500
});
try {
const r = await fetch(`${API}/file/${encodeURIComponent(selectedFile)}/section?${params}`);
const d = await r.json();
if (d.error) { setInfo(`Error: ${d.error}`); return; }
const bytes = Uint8Array.from(atob(d.data_b64), c => c.charCodeAt(0));
const floats = new Float32Array(bytes.buffer);
sectionData = {
num_traces: d.num_traces,
samples_per_trace: d.samples_per_trace,
trace_start: d.trace_start,
total_traces: d.total_traces,
vmin: d.vmin, vmax: d.vmax,
data: floats,
// Store time information for display
time_start: timeStart,
duration: duration,
sample_rate: currentSampleRate
};
// Format time display
const formatTime = (sec) => {
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
};
const loadInfo = document.getElementById('loadInfoPanel');
const loadText = document.getElementById('loadedTracesInfo');
loadInfo.style.display = 'block';
loadText.innerHTML = `<strong>${formatTime(timeStart)}</strong> <strong>${formatTime(timeStart + duration)}</strong> (${duration.toFixed(0)}s) · ${selectedChannel.split('/').pop()} · [${d.vmin.toFixed(3)}, ${d.vmax.toFixed(3)}]`;
setInfo(`${selectedFile} — ${formatTime(timeStart)}${formatTime(timeStart + duration)} (${duration}s) | ${currentSampleRate} Hz | range [${d.vmin.toFixed(2)}, ${d.vmax.toFixed(2)}]`);
renderSection();
} catch (e) {
setInfo(`Error: ${e.message}`);
}
}
function renderSection() {
if (!sectionData) return;
const gain = parseFloat(document.getElementById('inGain').value);
const clipPct = parseFloat(document.getElementById('inClip').value) / 100;
const cmap = document.getElementById('selCmap').value;
const norm = document.getElementById('selNorm').value;
const { num_traces, samples_per_trace, data, trace_start } = sectionData;
const z = [];
for (let s = 0; s < samples_per_trace; s++) {
const row = [];
for (let t = 0; t < num_traces; t++) {
row.push(data[t * samples_per_trace + s] * gain);
}
z.push(row);
}
let clipVal;
if (norm === 'global') {
const allVals = Array.from(data).map(v => Math.abs(v * gain));
allVals.sort((a, b) => a - b);
clipVal = allVals[Math.floor(allVals.length * clipPct)] || allVals[allVals.length - 1];
} else {
for (let s = 0; s < samples_per_trace; s++) {
for (let t = 0; t < num_traces; t++) {
const traceVals = [];
for (let ss = 0; ss < samples_per_trace; ss++) {
traceVals.push(Math.abs(data[t * samples_per_trace + ss]));
}
traceVals.sort((a, b) => a - b);
const trClip = traceVals[Math.floor(traceVals.length * clipPct)] || 1;
z[s][t] = (data[t * samples_per_trace + s] * gain) / trClip;
}
}
clipVal = 1;
}
const showDensity = currentView === 'density' || currentView === 'both';
const showWiggle = currentView === 'wiggle' || currentView === 'both';
const container = document.getElementById('heatmapContainer');
if (showDensity) {
// Generate time labels for axes
const { time_start, duration, sample_rate } = sectionData;
// X-axis: time across the recording (horizontal)
const timeLabels = Array.from({length: num_traces}, (_, i) => {
const t = time_start + (i / num_traces) * duration;
return t.toFixed(1);
});
// Y-axis: time within the window (vertical)
const windowDuration = samples_per_trace / sample_rate;
const windowTimeLabels = Array.from({length: samples_per_trace}, (_, i) => {
const t = (i / samples_per_trace) * windowDuration;
return t.toFixed(3);
});
Plotly.react('heatmapContainer', [{
z: z,
x: timeLabels,
y: windowTimeLabels,
type: 'heatmap',
colorscale: cmap,
zmin: -clipVal,
zmax: clipVal,
colorbar: { title: 'Amplitude', titlefont: { color: '#8899aa', size: 10 }, tickfont: { color: '#8899aa', size: 9 } },
hovertemplate: 'Temps: %{x}s<br>Fenêtre: %{y}s<br>Amp: %{z:.4f}<extra></extra>'
}], {
paper_bgcolor: '#1a1a2e',
plot_bgcolor: '#1a1a2e',
font: { color: '#eee' },
xaxis: { title: 'Temps (s)', color: '#8899aa', gridcolor: '#2a2a4a', type: 'linear' },
yaxis: { title: 'Temps fenêtre (s)', color: '#8899aa', gridcolor: '#2a2a4a', autorange: 'reversed' },
margin: { t: 10, r: 80, b: 40, l: 60 }
}, { responsive: true });
// Load shot overlay after waveform renders
const _wfS = parseFloat(document.getElementById(wfStart).value) || 0;
const _wfD = parseFloat(document.getElementById(wfDuration).value) || 3600;
setTimeout(function(){ loadShotOverlay(_wfS, _wfD); }, 300);
} else {
container.innerHTML = '';
}
const canvas = document.getElementById('wiggleCanvas');
if (showWiggle) {
drawWiggle(canvas, container, clipVal);
} else {
canvas.width = 0;
canvas.height = 0;
}
}
function drawWiggle(canvas, container, clipVal) {
if (!sectionData) return;
const { num_traces, samples_per_trace, data, trace_start } = sectionData;
const gain = parseFloat(document.getElementById('inGain').value);
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (currentView === 'wiggle') {
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, canvas.width, canvas.height);
canvas.style.pointerEvents = 'auto';
} else {
canvas.style.pointerEvents = 'none';
}
const ml = 60, mr = 80, mt = 10, mb = 40;
const pw = canvas.width - ml - mr;
const ph = canvas.height - mt - mb;
if (pw <= 0 || ph <= 0) return;
const traceSpacing = pw / num_traces;
const halfSpacing = traceSpacing * 0.45;
for (let t = 0; t < num_traces; t++) {
const cx = ml + (t + 0.5) * traceSpacing;
ctx.beginPath();
const fillPath = new Path2D();
fillPath.moveTo(cx, mt);
for (let s = 0; s < samples_per_trace; s++) {
const val = (data[t * samples_per_trace + s] * gain) / clipVal;
const x = cx + val * halfSpacing;
const y = mt + (s / (samples_per_trace - 1)) * ph;
if (s === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
fillPath.lineTo(x, y);
}
ctx.strokeStyle = currentView === 'both' ? 'rgba(0,0,0,0.6)' : '#00d4ff';
ctx.lineWidth = 0.5;
ctx.stroke();
fillPath.lineTo(cx, mt + ph);
fillPath.closePath();
ctx.save();
ctx.clip(fillPath);
ctx.fillStyle = currentView === 'both' ? 'rgba(0,0,0,0.3)' : 'rgba(0,212,255,0.3)';
ctx.fillRect(cx, mt, halfSpacing + 5, ph);
ctx.restore();
}
if (currentView === 'wiggle') {
ctx.strokeStyle = '#2a2a4a';
ctx.lineWidth = 1;
ctx.strokeRect(ml, mt, pw, ph);
ctx.fillStyle = '#8899aa';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
const step = Math.max(1, Math.floor(num_traces / 10));
for (let t = 0; t < num_traces; t += step) {
const x = ml + (t + 0.5) * traceSpacing;
const timeAtTrace = sectionData.time_start + (t / num_traces) * sectionData.duration;
ctx.fillText(timeAtTrace.toFixed(1), x, mt + ph + 15);
}
ctx.textAlign = 'right';
const ystep = Math.max(1, Math.floor(samples_per_trace / 8));
for (let s = 0; s < samples_per_trace; s += ystep) {
const y = mt + (s / (samples_per_trace - 1)) * ph;
const windowTime = (s / samples_per_trace) * (samples_per_trace / sectionData.sample_rate);
ctx.fillText(windowTime.toFixed(2), ml - 5, y + 3);
}
ctx.textAlign = 'center';
ctx.fillText('Temps (s)', ml + pw / 2, mt + ph + 32);
ctx.save();
ctx.translate(15, mt + ph / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('Temps fenêtre (s)', 0, 0);
ctx.restore();
}
}
function setView(v) {
currentView = v;
document.getElementById('btnDensity').classList.toggle('active', v === 'density');
document.getElementById('btnWiggle').classList.toggle('active', v === 'wiggle');
document.getElementById('btnBoth').classList.toggle('active', v === 'both');
renderSection();
}
function switchTab(tab) {
currentTab = tab;
const allTabs = ['section', 'metadata', 'map', 'extract', 'pipeline', 'geosup', 'headers', 'waveform', 'gather', 'coverage'];
document.querySelectorAll('.tab').forEach((el, i) => {
el.classList.toggle('active', allTabs[i] === tab);
});
allTabs.forEach(t => {
const el = document.getElementById(t + 'View');
if (el) el.classList.toggle('active', t === tab);
});
if (tab === 'metadata' && selectedFile) loadMetadata();
if (tab === 'map') initMap();
if (tab === 'pipeline') loadPipelineStatus();
if (tab === 'geosup') loadGeosupData();
if (tab === 'waveform' && selectedFile) loadWaveform();
if (tab === 'section') renderSection();
if (tab === 'headers' && selectedFile) loadHeaders();
if (tab === 'coverage' && !coverageData) loadCoverage();
// Re-render file list to show/hide checkboxes
filterFiles();
}
async function loadHeaders() {
if (!selectedFile) return;
const hc = document.getElementById('headersContent');
hc.innerHTML = '<div class="empty">Loading headers...</div>';
try {
const r = await fetch(`${API}/file/${encodeURIComponent(selectedFile)}/headers`);
const d = await r.json();
if (d.error) { hc.innerHTML = `<div class="empty" style="color:var(--accent2)">${d.error}</div>`; return; }
let html = '';
if (d.type === 'h5') {
if (d.file_attrs && Object.keys(d.file_attrs).length) {
html += '<h3 style="color:var(--accent);padding:8px 0;font-size:.85rem">File Attributes</h3>';
html += '<table class="htable"><tr><th>Key</th><th>Value</th></tr>';
for (const [k, v] of Object.entries(d.file_attrs)) { html += `<tr><td>${k}</td><td>${v}</td></tr>`; }
html += '</table>';
}
if (d.groups && d.groups.length) {
html += '<h3 style="color:var(--accent);padding:8px 0;font-size:.85rem">Groups</h3>';
html += '<table class="htable"><tr><th>Path</th><th>Attributes</th></tr>';
d.groups.forEach(g => {
const attrs = Object.entries(g).filter(([k]) => k !== 'path').map(([k, v]) => `${k}: ${v}`).join(', ');
html += `<tr><td>${g.path}</td><td>${attrs}</td></tr>`;
});
html += '</table>';
}
html += '<h3 style="color:var(--accent);padding:8px 0;font-size:.85rem">Datasets</h3>';
html += '<table class="htable"><tr><th>Path</th><th>Shape</th><th>Dtype</th><th>Size</th><th>Attributes</th></tr>';
(d.datasets || []).forEach(ds => {
const attrs = Object.entries(ds).filter(([k]) => k.startsWith('attr:')).map(([k, v]) => `${k.slice(5)}: ${v}`).join(', ');
html += `<tr><td>${ds.path}</td><td>${JSON.stringify(ds.shape)}</td><td>${ds.dtype}</td><td>${ds.size?.toLocaleString()}</td><td>${attrs || '—'}</td></tr>`;
});
html += '</table>';
} else if (d.type === 'segy') {
if (d.binary_header) {
html += '<h3 style="color:var(--accent);padding:8px 0;font-size:.85rem">Binary File Header</h3>';
html += '<table class="htable"><tr><th>Field</th><th>Value</th></tr>';
for (const [k, v] of Object.entries(d.binary_header)) { html += `<tr><td>${k.replace(/_/g, ' ')}</td><td>${v}</td></tr>`; }
html += '</table>';
}
if (d.trace_headers && d.trace_headers.length) {
html += `<h3 style="color:var(--accent);padding:8px 0;font-size:.85rem">Trace Headers (${d.trace_headers.length} of ${d.total_traces})</h3>`;
const cols = Object.keys(d.trace_headers[0]);
html += '<table class="htable"><tr>' + cols.map(c => `<th>${c}</th>`).join('') + '</tr>';
d.trace_headers.forEach(th => { html += '<tr>' + cols.map(c => `<td>${th[c] ?? '—'}</td>`).join('') + '</tr>'; });
html += '</table>';
}
}
hc.innerHTML = html || '<div class="empty">No header data</div>';
} catch (e) { hc.innerHTML = `<div class="empty" style="color:var(--accent2)">Error: ${e.message}</div>`; }
}
const waveformColors = ['#00d4ff', '#ff6b6b', '#51cf66', '#fcc419', '#cc5de8', '#ff922b', '#20c997', '#e599f7'];
function populateWaveformChannels(info) {
const container = document.getElementById('waveformChannelChecks');
if (!container) return;
const cals = (info.datasets || []).filter(d => d.path.includes('calibrated'));
const raws = (info.datasets || []).filter(d => d.path.includes('raw'));
let html = '';
const allCh = [...cals, ...raws];
allCh.forEach((d, i) => {
const label = d.path.split('/').pop();
const group = d.path.includes('calibrated') ? 'cal' : 'raw';
const checked = d.path === selectedChannel ? 'checked' : '';
const color = waveformColors[i % waveformColors.length];
html += `<label style="display:flex;align-items:center;gap:3px;font-size:.75rem;color:${color};cursor:pointer">` +
`<input type="checkbox" class="wf-ch-check" value="${d.path}" ${checked} style="accent-color:${color}">` +
`<span>${label}</span><span style="color:var(--muted);font-size:.6rem">(${group})</span></label>`;
});
container.innerHTML = html;
}
async function loadWaveform() {
if (!selectedFile || !selectedFile.endsWith('.h5')) {
document.getElementById('waveformChart').innerHTML = '<div class="empty">Waveform view available for H5 files</div>';
return;
}
// Gather checked channels
const checks = document.querySelectorAll('.wf-ch-check:checked');
const channels = Array.from(checks).map(c => c.value);
if (channels.length === 0) {
document.getElementById('waveformChart').innerHTML = '<div class="empty">Select at least one channel</div>';
return;
}
document.getElementById('waveformChart').innerHTML = '<div class="empty">Loading...</div>';
// Build query params
const params = new URLSearchParams({ points: 5000, channels: channels.join(',') });
const wfStart = document.getElementById('wfStart').value;
const wfDuration = document.getElementById('wfDuration').value;
if (wfStart) params.set('start', wfStart);
if (wfDuration) params.set('duration', wfDuration);
const r = await fetch(`${API}/file/${encodeURIComponent(selectedFile)}/waveform?${params}`);
const d = await r.json();
if (d.error) { document.getElementById('waveformChart').innerHTML = `<div class="empty">${d.error}</div>`; return; }
// Build Plotly traces for each channel
const traces = [];
const annParts = [];
channels.forEach((ch, i) => {
const chData = d.channels[ch];
if (!chData) return;
const color = waveformColors[i % waveformColors.length];
traces.push({
x: chData.x, y: chData.y, type: 'scattergl', mode: 'lines',
line: { color: color, width: 1 },
name: ch.split('/').pop()
});
annParts.push(`${ch.split('/').pop()}: [${chData.min.toFixed(3)}, ${chData.max.toFixed(3)}]`);
});
Plotly.newPlot('waveformChart', traces, {
paper_bgcolor: '#1a1a2e', plot_bgcolor: '#1a1a2e',
font: { color: '#eee' },
xaxis: { title: 'Time (s)', color: '#8899aa', gridcolor: '#2a2a4a' },
yaxis: { title: 'Amplitude', color: '#8899aa', gridcolor: '#2a2a4a' },
margin: { t: 10, r: 20, b: 40, l: 60 },
legend: { font: { size: 10, color: '#8899aa' }, bgcolor: 'rgba(26,26,46,0.8)' },
annotations: [{
x: 0.01, y: 0.98, xref: 'paper', yref: 'paper', showarrow: false,
text: `${d.total_samples.toLocaleString()} total samples @ ${d.sample_rate}Hz | ${annParts.join(' · ')}`,
font: { size: 10, color: '#8899aa' }, bgcolor: '#16213e', borderpad: 4
}]
}, { responsive: true });
}
function setupResize() {
const handle = document.getElementById('resizeHandle');
const sidebar = document.getElementById('sidebar');
let dragging = false, startX, startW;
handle.addEventListener('mousedown', e => {
dragging = true; startX = e.clientX; startW = sidebar.offsetWidth;
document.body.style.cursor = 'col-resize';
e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
sidebar.style.width = Math.max(150, Math.min(500, startW + e.clientX - startX)) + 'px';
});
document.addEventListener('mouseup', () => {
if (dragging) { dragging = false; document.body.style.cursor = ''; }
});
}
function setInfo(msg) {
document.getElementById('infopanel').innerHTML = `<span>${msg}</span><span>${new Date().toLocaleTimeString('fr')}</span>`;
}
let geosupData = null;
let geosupLayers = { sources: null, receivers: null, deployments: null, realshots: null };
let layerVisibility = { sources: false, receivers: false, deployments: false, files: true };
async function loadGeosupData() {
try {
const posResp = await fetch(API + '/geosup/positions');
geosupData = await posResp.json();
if (geosupData.counts) {
document.getElementById('statSources').textContent = geosupData.counts.sources.toLocaleString();
document.getElementById('statReceivers').textContent = geosupData.counts.receivers.toLocaleString();
document.getElementById('statDeployments').textContent = geosupData.counts.deployments.toLocaleString();
}
const docResp = await fetch(API + '/geosup/documents');
const docData = await docResp.json();
document.getElementById('statDocs').textContent = docData.count || 0;
const docList = document.getElementById('docList');
if (docData.documents && docData.documents.length) {
docList.innerHTML = docData.documents.map(doc => `
<div class="doc-item" onclick="downloadDoc('${doc.name}')">
<span class="icon">${doc.icon}</span>
<div class="info">
<div class="name">${doc.name}</div>
<div class="meta">${doc.type} • ${doc.size_human}</div>
</div>
${doc.is_gundalf ? '<span class="badge gundalf">GUNDALF</span>' : ''}
${doc.is_params ? '<span class="badge">PARAMS</span>' : ''}
</div>
`).join('');
} else {
docList.innerHTML = '<div class="empty">No documents found</div>';
}
} catch (e) {
console.error('Geosup load error:', e);
}
}
function downloadDoc(filename) {
window.open(API + '/geosup/documents/' + encodeURIComponent(filename), '_blank');
}
async function loadGeosupLayers() {
if (window._geosupLoaded) return;
window._geosupLoaded = true;
if (!map) {
setTimeout(loadGeosupLayers, 500);
return;
}
if (!geosupData) {
const resp = await fetch(API + '/geosup/positions');
geosupData = await resp.json();
}
if (!geosupData.features) return;
Object.values(geosupLayers).forEach(layer => {
if (layer) map.removeLayer(layer);
});
const sourceMarkers = [];
const receiverMarkers = [];
const deploymentMarkers = [];
geosupData.features.forEach(feature => {
const coords = feature.geometry.coordinates;
const props = feature.properties;
const popup = `<b>${props.category.toUpperCase()}</b><br>
Line: ${props.line}, Point: ${props.point}<br>
E: ${props.easting?.toFixed(1)}, N: ${props.northing?.toFixed(1)}<br>
Depth: ${props.depth}m${props.timestamp ? '<br>Time: ' + props.timestamp : ''}`;
const color = props.category === 'source' ? '#3b82f6' :
props.category === 'receiver' ? '#ef4444' : '#22c55e';
const marker = L.circleMarker([coords[1], coords[0]], {
radius: 4,
fillColor: color,
color: color,
weight: 1,
opacity: 0.8,
fillOpacity: 0.6
}).bindPopup(popup);
if (props.category === 'source') sourceMarkers.push(marker);
else if (props.category === 'receiver') receiverMarkers.push(marker);
else deploymentMarkers.push(marker);
});
geosupLayers.sources = L.layerGroup(sourceMarkers);
geosupLayers.receivers = L.layerGroup(receiverMarkers);
geosupLayers.deployments = L.layerGroup(deploymentMarkers);
// Don't auto-add - let checkboxes control visibility
document.getElementById('layerSources').checked = false;
document.getElementById('layerReceivers').checked = false;
document.getElementById('layerDeployments').checked = false;
layerVisibility.sources = false;
layerVisibility.receivers = false;
layerVisibility.deployments = false;
// Load aslaid (real node positions) from darfNodes
if (darfNodes && Object.keys(darfNodes).length > 0) {
var aslaidMarkers = [];
Object.entries(darfNodes).forEach(function([bid, node]) {
if (node.aslaid_lat && node.aslaid_lon) {
var popup = '<b>Aslaid Node b' + bid + '</b><br>Line: ' + node.line + ', Point: ' + node.point +
'<br>Lat: ' + node.aslaid_lat + ', Lon: ' + node.aslaid_lon +
'<br>Depth: ' + (node.aslaid_d || node.depth) + 'm' +
'<br>E: ' + (node.aslaid_e||'').toLocaleString() + ', N: ' + (node.aslaid_n||'').toLocaleString();
aslaidMarkers.push(L.circleMarker([node.aslaid_lat, node.aslaid_lon], {
radius: 5, fillColor: '#f59e0b', color: '#f59e0b',
weight: 1, opacity: 0.9, fillOpacity: 0.7
}).bindPopup(popup));
}
});
if (aslaidMarkers.length > 0) {
geosupLayers.aslaid = L.layerGroup(aslaidMarkers);
document.getElementById('layerAslaid').checked = false;
setInfo('Loaded ' + aslaidMarkers.length + ' aslaid node positions');
}
}
// Load real shot positions
try {
const rsResp = await fetch(API + '/real_shots');
const rsData = await rsResp.json();
if (rsData.features) {
const rsMarkers = rsData.features.map(function(f) {
var c = f.geometry.coordinates;
var p = f.properties;
return L.circleMarker([c[1], c[0]], {
radius: 3, fillColor: '#ff6600', color: '#ff6600',
weight: 1, opacity: 0.7, fillOpacity: 0.5
}).bindPopup('<b>Real Shot</b><br>Line: ' + p.line + '<br>Time: ' + p.time + '<br>E: ' + p.easting + ' N: ' + p.northing);
});
geosupLayers.realshots = L.layerGroup(rsMarkers);
document.getElementById('layerRealShots').checked = false;
setInfo('Loaded ' + rsData.features.length + ' real shot positions');
}
} catch(e) { console.warn('Real shots load error:', e); }
if (geosupData.features.length > 0) {
const bounds = L.latLngBounds(geosupData.features.map(f => [f.geometry.coordinates[1], f.geometry.coordinates[0]]));
map.fitBounds(bounds, { padding: [20, 20] });
}
setInfo(`Loaded ${geosupData.counts.total} geosup positions`);
}
function toggleLayer(layerName) {
var idMap = {sources:'layerSources', receivers:'layerReceivers', deployments:'layerDeployments', aslaid:'layerAslaid', realshots:'layerRealShots', files:'layerFiles'};
var checkbox = document.getElementById(idMap[layerName] || ('layer' + layerName.charAt(0).toUpperCase() + layerName.slice(1)));
if (!checkbox) return;
layerVisibility[layerName] = checkbox.checked;
if (layerName === 'files') {
updateMapMarkers();
} else if (geosupLayers[layerName]) {
if (layerVisibility[layerName]) {
geosupLayers[layerName].addTo(map);
} else {
map.removeLayer(geosupLayers[layerName]);
}
}
}
window.addEventListener('resize', () => {
if (currentTab === 'section') renderSection();
if (currentTab === 'gather') renderGather();
if (map) map.invalidateSize();
});
// ========== GATHER FUNCTIONS ==========
function populateGatherLineDropdown() {
const lines = new Set();
Object.values(darfNodes).forEach(n => { if (n.line) lines.add(n.line); });
const sorted = Array.from(lines).sort((a, b) => a - b);
const dd = document.getElementById('gatherLineDropdown');
dd.innerHTML = '<option value="">-- All lines --</option>' +
sorted.map(l => `<option value="${l}">Line ${l}</option>`).join('');
}
function boardToLine(boardId) {
const node = darfNodes[boardId];
return node ? node.line : null;
}
function selectGatherLine(lineVal) {
if (!lineVal) return;
const line = parseInt(lineVal);
// Find all board_ids on this line
const boardIds = new Set();
Object.entries(darfNodes).forEach(([bid, n]) => {
if (n.line === line) boardIds.add(bid);
});
// Select files whose board_id is on this line
files.forEach(f => {
const bid = extractBoardIdJs(f.name);
if (bid && boardIds.has(bid)) {
gatherSelectedFiles.add(f.name);
}
});
updateGatherSelCount();
filterFiles();
}
function selectAllGatherFiles() {
files.forEach(f => { if (f.name.endsWith('.h5')) gatherSelectedFiles.add(f.name); });
updateGatherSelCount();
filterFiles();
}
function clearGatherFiles() {
gatherSelectedFiles.clear();
updateGatherSelCount();
filterFiles();
}
function toggleGatherFile(name, checked) {
if (checked) gatherSelectedFiles.add(name);
else gatherSelectedFiles.delete(name);
updateGatherSelCount();
}
function updateGatherSelCount() {
document.getElementById('gatherSelCount').textContent = gatherSelectedFiles.size + ' selected';
}
function extractBoardIdJs(filename) {
const m = filename.match(/_b(\d+)_/);
return m ? m[1] : null;
}
let gatherOverlapInfo = null;
let gatherActiveGroup = 0;
async function loadGather(groupIdx) {
if (gatherSelectedFiles.size === 0) {
setInfo('Gather: select files first');
return;
}
setInfo('Scanning file windows...');
document.getElementById('gatherEmpty').style.display = 'flex';
document.getElementById('gatherEmpty').textContent = 'Scanning time windows...';
const channel = document.getElementById('gatherChannel').value;
const start = parseFloat(document.getElementById('gatherStart').value);
const duration = parseFloat(document.getElementById('gatherDuration').value);
const allFiles = Array.from(gatherSelectedFiles).join(',');
try {
// Step 1: Get overlap groups
const infoR = await fetch(API + '/gather_info', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({files: allFiles})
});
const info = await infoR.json();
gatherOverlapInfo = info;
if (!info.groups || info.groups.length === 0) {
document.getElementById('gatherEmpty').textContent = 'No valid files found';
return;
}
// Pick group
if (groupIdx !== undefined) gatherActiveGroup = groupIdx;
else gatherActiveGroup = 0; // best group (largest)
var grp = info.groups[gatherActiveGroup];
// Show group selector if multiple groups
var groupBar = document.getElementById('gatherGroupBar');
if (!groupBar) {
groupBar = document.createElement('div');
groupBar.id = 'gatherGroupBar';
groupBar.style.cssText = 'padding:4px 8px;display:flex;flex-wrap:wrap;gap:4px;align-items:center;font-size:12px;background:rgba(59,130,246,0.1);border-radius:4px;margin-bottom:6px';
var gatherPanel = document.getElementById('gatherCanvasWrap');
if (gatherPanel) gatherPanel.parentNode.insertBefore(groupBar, gatherPanel);
}
if (info.num_groups > 1) {
var html = '<span style="color:var(--muted);margin-right:4px">Sessions:</span>';
info.groups.forEach(function(g, i) {
var active = i === gatherActiveGroup;
html += '<button onclick="loadGather(' + i + ')" style="padding:2px 8px;font-size:11px;border-radius:3px;cursor:pointer;border:1px solid ' + (active ? 'var(--accent)' : 'var(--border)') + ';background:' + (active ? 'var(--accent)' : 'var(--surface)') + ';color:' + (active ? '#fff' : 'var(--text)') + '">' + g.date + ' (' + g.count + ' traces)</button>';
});
html += '<span style="color:var(--muted);margin-left:8px">' + grp.count + '/' + info.total_files + ' synced';
var uh = Math.floor((grp.union_duration||grp.overlap_duration) / 3600);
var um = Math.floor(((grp.union_duration||grp.overlap_duration) % 3600) / 60);
html += ' · span ' + uh + 'h' + (um < 10 ? '0' : '') + um + 'm';
html += '</span>';
groupBar.innerHTML = html;
groupBar.style.display = 'flex';
} else {
groupBar.style.display = 'none';
}
// Update slider max to group overlap
updateGatherSliderMax(grp.union_duration || grp.overlap_duration || 86400);
// Step 2: Load only the files from selected group
var syncFiles = grp.files.join(',');
setInfo('Loading ' + grp.count + ' traces from ' + grp.date + '...');
document.getElementById('gatherEmpty').textContent = 'Loading ' + grp.count + ' traces...';
const r = await fetch(API + '/gather', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
files: syncFiles, channel: channel,
start: start, duration: duration, points: Math.min(duration * 500, 50000),
sync: true
})
});
const d = await r.json();
if (d.error) {
document.getElementById('gatherEmpty').textContent = 'Error: ' + d.error;
return;
}
gatherData = d;
document.getElementById('gatherEmpty').style.display = 'none';
var syncInfo = grp.date;
if (d.date_start) syncInfo = d.date_start;
var decInfo = '';
if (d.decimation && d.decimation.is_decimated) {
decInfo = ' | ⚠️ Display decimated ' + d.decimation.ratio + '× (' + d.decimation.display_points + '/' + d.decimation.original_samples.toLocaleString() + ' pts) — Extract for full res';
} else {
decInfo = ' | ✅ Full resolution';
}
setInfo('Gather: ' + d.traces.length + ' traces synced | Session: ' + syncInfo + decInfo);
renderGather();
} catch (e) {
document.getElementById('gatherEmpty').textContent = 'Error: ' + e.message;
setInfo('Gather error: ' + e.message);
}
}
function renderGather() {
if (!gatherData || !gatherData.traces.length) return;
// Filter out empty traces
var validTraces = gatherData.traces.filter(function(t) { return !t.empty && t.y && t.y.length > 0; });
const canvas = document.getElementById('gatherCanvas');
const wrap = document.getElementById('gatherCanvasWrap');
const spacing = parseInt(document.getElementById('gatherSpacing').value);
const gain = parseFloat(document.getElementById('gatherGain').value);
const norm = document.getElementById('gatherNorm').value;
const sortBy = document.getElementById('gatherSort').value;
const mode = document.getElementById('gatherMode').value;
// Sort traces
let traces = validTraces ? [...validTraces] : [...gatherData.traces.filter(function(t) { return !t.empty && t.y && t.y.length > 0; })];
if (sortBy === 'board_id') {
traces.sort((a, b) => (parseInt(a.board_id) || 0) - (parseInt(b.board_id) || 0));
} else if (sortBy === 'line_point') {
traces.sort((a, b) => {
const na = darfNodes[a.board_id] || {};
const nb = darfNodes[b.board_id] || {};
return (na.line || 0) - (nb.line || 0) || (na.point || 0) - (nb.point || 0);
});
} else if (sortBy === 'distance') {
// Sort by preplot_e (proxy for distance along line)
traces.sort((a, b) => {
const na = darfNodes[a.board_id] || {};
const nb = darfNodes[b.board_id] || {};
const da = Math.sqrt((na.preplot_e || 0) ** 2 + (na.preplot_n || 0) ** 2);
const db = Math.sqrt((nb.preplot_e || 0) ** 2 + (nb.preplot_n || 0) ** 2);
return da - db;
});
}
const nTraces = traces.length;
const ml = 90, mr = 20, mt = 30, mb = 40;
const canvasW = wrap.clientWidth - 10;
const wrapH = wrap.clientHeight || 500;
const canvasH = Math.max(wrapH, mt + mb + nTraces * spacing);
canvas.width = canvasW;
canvas.height = canvasH;
canvas.style.width = canvasW + 'px';
canvas.style.height = canvasH + 'px';
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, canvasW, canvasH);
const pw = canvasW - ml - mr;
const halfSpacing = spacing * 0.45;
// Global normalization
let globalMax = 1;
if (norm === 'global') {
traces.forEach(tr => {
const absMax = Math.max(Math.abs(tr.min), Math.abs(tr.max));
if (absMax > globalMax) globalMax = absMax;
});
}
const timeAxis = gatherData.time_axis;
const nPts = timeAxis.length || (traces[0] ? traces[0].y.length : 0);
// Trace colors (defined once, used in wiggle + labels + legend)
const TRACE_COLORS = ['#00d4ff','#ff6b6b','#51cf66','#fcc419','#cc5de8','#ff922b','#20c997','#e599f7','#74c0fc','#f06595','#94d82d','#4ecdc4'];
if (mode === 'density') {
// Density rendering using imageData
for (let ti = 0; ti < nTraces; ti++) {
const tr = traces[ti];
const cy = mt + ti * spacing;
const trMax = norm === 'trace' ? Math.max(Math.abs(tr.min), Math.abs(tr.max)) || 1 : globalMax;
for (let pi = 0; pi < tr.y.length; pi++) {
const x = ml + (pi / tr.y.length) * pw;
const val = (tr.y[pi] * gain) / trMax;
const clamped = Math.max(-1, Math.min(1, val));
// Blue-white-red colormap
let r, g, b;
if (clamped < 0) {
r = Math.floor(255 * (1 + clamped));
g = Math.floor(255 * (1 + clamped));
b = 255;
} else {
r = 255;
g = Math.floor(255 * (1 - clamped));
b = Math.floor(255 * (1 - clamped));
}
const h = Math.max(1, Math.floor(spacing * 0.8));
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect(Math.floor(x), Math.floor(cy - h / 2), Math.max(1, Math.ceil(pw / tr.y.length)), h);
}
}
} else {
// Wiggle rendering
for (let ti = 0; ti < nTraces; ti++) {
const tr = traces[ti];
const cy = mt + ti * spacing;
const trMax = norm === 'trace' ? Math.max(Math.abs(tr.min), Math.abs(tr.max)) || 1 : globalMax;
// Draw zero line
ctx.strokeStyle = '#2a2a4a';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(ml, cy);
ctx.lineTo(ml + pw, cy);
ctx.stroke();
// Draw trace
ctx.beginPath();
const fillPath = new Path2D();
fillPath.moveTo(ml, cy);
for (let pi = 0; pi < tr.y.length; pi++) {
const x = ml + (pi / tr.y.length) * pw;
const val = (tr.y[pi] * gain) / trMax;
const y = cy - val * halfSpacing;
if (pi === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
fillPath.lineTo(x, y);
}
/* colors moved to TRACE_COLORS */ const _tc = ['#00d4ff','#ff6b6b','#51cf66','#fcc419','#cc5de8','#ff922b','#20c997','#e599f7','#74c0fc','#f06595','#94d82d','#4ecdc4']; ctx.strokeStyle = TRACE_COLORS[ti % TRACE_COLORS.length];
ctx.lineWidth = 0.8;
ctx.stroke();
// Fill positive
fillPath.lineTo(ml + pw, cy);
fillPath.closePath();
ctx.save();
ctx.clip(fillPath);
const fc = TRACE_COLORS[ti % TRACE_COLORS.length]; const [rr,gg,bb] = fc.match(/\w{2}/g) ? [parseInt(fc.slice(1,3),16),parseInt(fc.slice(3,5),16),parseInt(fc.slice(5,7),16)] : [0,212,255]; ctx.fillStyle = `rgba(${rr},${gg},${bb},0.15)`;
ctx.fillRect(ml, cy - halfSpacing, pw, halfSpacing);
ctx.restore();
}
}
// Draw Y-axis labels (board_id)
// Using TRACE_COLORS for labels
ctx.font = '10px monospace';
ctx.textAlign = 'right';
for (let ti = 0; ti < nTraces; ti++) {
const tr = traces[ti];
const cy = mt + ti * spacing;
ctx.fillStyle = TRACE_COLORS[ti % TRACE_COLORS.length];
const label = tr.board_id || '?';
const node = darfNodes[tr.board_id] || {};
const lineInfo = node.line ? ` L${node.line}` : '';
ctx.fillText(label + lineInfo, ml - 6, cy + 3);
}
// Draw X-axis (time)
ctx.strokeStyle = '#2a2a4a';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ml, canvasH - mb);
ctx.lineTo(ml + pw, canvasH - mb);
ctx.stroke();
if (timeAxis.length > 1) {
ctx.textAlign = 'center';
ctx.fillStyle = '#8899aa';
ctx.font = '10px sans-serif';
const tMin = timeAxis[0];
const tMax = timeAxis[timeAxis.length - 1];
const nTicks = Math.min(10, Math.floor(pw / 60));
for (let i = 0; i <= nTicks; i++) {
const t = tMin + (i / nTicks) * (tMax - tMin);
const x = ml + (i / nTicks) * pw;
// Show absolute time if sync data available
let timeLabel;
if (gatherData && gatherData.window_start) {
const absUnix = gatherData.window_start + t;
const d2 = new Date(absUnix * 1000);
timeLabel = d2.getUTCHours().toString().padStart(2,'0') + ':' + d2.getUTCMinutes().toString().padStart(2,'0') + ':' + d2.getUTCSeconds().toString().padStart(2,'0');
} else {
const absT = t + parseFloat(document.getElementById('gatherStart').value || 0);
timeLabel = fmtTime(absT);
}
ctx.fillText(timeLabel, x, canvasH - mb + 14);
ctx.beginPath();
ctx.moveTo(x, canvasH - mb);
ctx.lineTo(x, canvasH - mb + 4);
ctx.stroke();
}
ctx.fillText('Time', ml + pw / 2, canvasH - mb + 28);
}
// Title
ctx.fillStyle = '#00d4ff';
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
let gatherTitle = `Gather: ${nTraces} traces, ${mode} mode`;
if (gatherData && gatherData.date_start) {
const ws = gatherData.window_start;
const we = ws + parseFloat(document.getElementById('gatherDuration').value || 60);
const d1 = new Date(ws * 1000);
const d2 = new Date(we * 1000);
const fmt = (d) => d.getUTCFullYear()+'-'+(d.getUTCMonth()+1).toString().padStart(2,'0')+'-'+d.getUTCDate().toString().padStart(2,'0')+' '+d.getUTCHours().toString().padStart(2,'0')+':'+d.getUTCMinutes().toString().padStart(2,'0');
gatherTitle += ` | ${fmt(d1)} → ${fmt(d2)} UTC`;
}
ctx.fillText(gatherTitle, ml, 16);
// Color legend
const legendX = ml + 250;
for (let ti = 0; ti < Math.min(nTraces, 12); ti++) {
const tr = traces[ti];
const lx = legendX + (ti % 6) * 100;
const ly = ti < 6 ? 10 : 22;
ctx.fillStyle = TRACE_COLORS[ti % TRACE_COLORS.length];
ctx.fillRect(lx, ly - 4, 8, 8);
ctx.fillStyle = '#aaa';
ctx.font = '9px monospace';
ctx.textAlign = 'left';
ctx.fillText('b' + (tr.board_id || '?'), lx + 10, ly + 3);
}
// Stats bar
document.getElementById('gatherStats').innerHTML =
`<span>${nTraces} traces</span> · <span>Gain: ${gain}</span> · <span>Spacing: ${spacing}px</span> · <span>Norm: ${norm}</span> · <span>Sort: ${sortBy}</span>`;
}
// ========== COVERAGE FUNCTIONS ==========
const LINE_COLORS = ['#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1'];
async function loadCoverage() {
document.getElementById('coverageEmpty').style.display = 'flex';
document.getElementById('coverageEmpty').textContent = 'Loading coverage data...';
try {
const r = await fetch(API + '/coverage');
const d = await r.json();
if (d.error) {
document.getElementById('coverageEmpty').textContent = 'Error: ' + d.error;
return;
}
coverageData = d;
document.getElementById('coverageEmpty').style.display = 'none';
renderCoverage();
} catch (e) {
document.getElementById('coverageEmpty').textContent = 'Error: ' + e.message;
}
}
function renderCoverage() {
if (!coverageData || !coverageData.files.length) return;
const files = coverageData.files;
const wrap = document.getElementById('coverageChartWrap');
// Parse dates and compute extents
const entries = files.map(f => {
let startDate = null;
if (f.date_str) {
try { startDate = new Date(f.date_str); } catch (e) {}
if (startDate && isNaN(startDate.getTime())) startDate = null;
}
return { ...f, startDate, endDate: startDate ? new Date(startDate.getTime() + (f.duration_sec || 0) * 1000) : null };
}).filter(e => e.startDate);
if (entries.length === 0) {
wrap.innerHTML = '<div class="empty">No files with valid dates found</div>';
return;
}
// Compute line set and assign colors
const lineSet = new Set();
entries.forEach(e => { if (e.line != null) lineSet.add(e.line); });
const linesSorted = Array.from(lineSet).sort((a, b) => a - b);
const lineColorMap = {};
linesSorted.forEach((l, i) => { lineColorMap[l] = LINE_COLORS[i % LINE_COLORS.length]; });
// Stats
const uniqueBoards = new Set(entries.map(e => e.board_id).filter(Boolean));
const totalHours = entries.reduce((s, e) => s + (e.duration_sec || 0), 0) / 3600;
document.getElementById('covNodes').textContent = uniqueBoards.size;
document.getElementById('covFiles').textContent = entries.length;
document.getElementById('covHours').textContent = totalHours.toFixed(1);
document.getElementById('covLines').textContent = linesSorted.length;
// Legend
document.getElementById('coverageLegend').innerHTML = linesSorted.map(l =>
`<div class="coverage-legend-item"><div class="swatch" style="background:${lineColorMap[l]}"></div>Line ${l}</div>`
).join('');
// Time extent
const allStarts = entries.map(e => e.startDate.getTime());
const allEnds = entries.map(e => e.endDate.getTime());
const tMin = Math.min(...allStarts);
const tMax = Math.max(...allEnds);
const tRange = tMax - tMin || 1;
// Group by board_id and sort
const byBoard = {};
entries.forEach(e => {
const key = e.board_id || e.filename;
if (!byBoard[key]) byBoard[key] = { board_id: e.board_id, line: e.line, point: e.point, segments: [] };
byBoard[key].segments.push(e);
});
const rows = Object.values(byBoard).sort((a, b) => (a.line || 0) - (b.line || 0) || (a.point || 0) - (b.point || 0));
// SVG dimensions
const ml = 100, mr = 20, mt = 30, mb = 50;
const rowH = 18, rowGap = 2;
const svgW = wrap.clientWidth - 20;
const svgH = mt + mb + rows.length * (rowH + rowGap);
const pw = svgW - ml - mr;
let svg = `<svg width="${svgW}" height="${svgH}" xmlns="http://www.w3.org/2000/svg" style="font-family:sans-serif">`;
// Background
svg += `<rect width="${svgW}" height="${svgH}" fill="#1a1a2e"/>`;
// Time axis
const nTicks = Math.min(12, Math.floor(pw / 80));
for (let i = 0; i <= nTicks; i++) {
const t = tMin + (i / nTicks) * tRange;
const x = ml + (i / nTicks) * pw;
const d = new Date(t);
const label = `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
svg += `<text x="${x}" y="${mt - 8}" fill="#8899aa" font-size="9" text-anchor="middle">${label}</text>`;
svg += `<line x1="${x}" y1="${mt - 2}" x2="${x}" y2="${mt + rows.length * (rowH + rowGap)}" stroke="#2a2a4a" stroke-width="0.5"/>`;
}
// Rows
rows.forEach((row, ri) => {
const y = mt + ri * (rowH + rowGap);
const label = row.board_id ? `${row.board_id}` : '?';
const lineLabel = row.line ? ` L${row.line}` : '';
svg += `<text x="${ml - 6}" y="${y + rowH / 2 + 3}" fill="#8899aa" font-size="9" text-anchor="end">${label}${lineLabel}</text>`;
// Alternating row bg
if (ri % 2 === 0) {
svg += `<rect x="${ml}" y="${y}" width="${pw}" height="${rowH}" fill="rgba(255,255,255,0.02)"/>`;
}
row.segments.forEach(seg => {
const x1 = ml + ((seg.startDate.getTime() - tMin) / tRange) * pw;
const w = Math.max(2, ((seg.duration_sec || 0) / (tRange / 1000)) * pw);
const color = lineColorMap[seg.line] || '#666';
const escapedFn = (seg.filename||seg.name).replace(/'/g, "\\'");
svg += `<rect x="${x1}" y="${y + 1}" width="${w}" height="${rowH - 2}" fill="${color}" rx="2" opacity="0.8" style="cursor:pointer" onclick="coverageClickFile('${escapedFn}')">`;
svg += `<title>${(seg.filename||seg.name)}\nBoard: ${seg.board_id || '?'}\nLine: ${seg.line || '?'} Point: ${seg.point || '?'}\nDuration: ${((seg.duration_sec || 0) / 3600).toFixed(1)}h\nStart: ${seg.date_str}</title>`;
svg += `</rect>`;
});
});
// Axis labels
svg += `<text x="${ml + pw / 2}" y="${svgH - 10}" fill="#8899aa" font-size="10" text-anchor="middle">Recording Time</text>`;
svg += `<text x="12" y="${mt + rows.length * (rowH + rowGap) / 2}" fill="#8899aa" font-size="10" text-anchor="middle" transform="rotate(-90,12,${mt + rows.length * (rowH + rowGap) / 2})">Node (Board ID)</text>`;
svg += '</svg>';
wrap.innerHTML = svg;
}
function coverageClickFile(filename) {
selectFile(filename);
setInfo('Coverage: selected ' + filename);
}
(async function bootInit() {
for (let attempt = 0; attempt < 5; attempt++) {
try {
await init();
console.log('init OK, files=' + files.length);
return;
} catch(e) {
console.warn('init attempt ' + (attempt+1) + ' failed:', e.message);
await new Promise(r => setTimeout(r, 2000));
}
}
console.error('init failed after 5 attempts');
})();
// Format seconds as H:MM:SS or M:SS
function fmtTime(s) {
s = Math.round(s);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return h + ':' + String(m).padStart(2,'0') + ':' + String(sec).padStart(2,'0');
return m + ':' + String(sec).padStart(2,'0');
}
let gatherMaxDuration = 8000;
let gatherSliderDebounce = null;
// Called when slider is dragged — live update
function onGatherSliderInput(val) {
val = parseInt(val);
document.getElementById('gatherStart').value = val;
document.getElementById('gatherSliderLabel').textContent = fmtTime(val);
// Debounce: reload after 300ms of no sliding
clearTimeout(gatherSliderDebounce);
gatherSliderDebounce = setTimeout(() => { if (gatherSelectedFiles.size > 0) loadGather(); }, 300);
}
// Called when number inputs change
function onGatherParamChange() {
const start = parseInt(document.getElementById('gatherStart').value) || 0;
document.getElementById('gatherTimeSlider').value = start;
document.getElementById('gatherSliderLabel').textContent = fmtTime(start);
if (gatherSelectedFiles.size > 0) loadGather();
}
// Update slider max when file info is known
function updateGatherSliderMax(maxDur) {
gatherMaxDuration = Math.floor(maxDur);
const slider = document.getElementById('gatherTimeSlider');
slider.max = gatherMaxDuration;
document.getElementById('gatherSliderMax').textContent = fmtTime(gatherMaxDuration);
const durEl = document.getElementById('gatherDuration');
durEl.max = gatherMaxDuration;
}
// === Shot Time Overlay ===
async function loadShotOverlay(startSec, durationSec) {
if (!selectedFile) return;
try {
const params = new URLSearchParams({
file: selectedFile, start: startSec || 0, duration: durationSec || 3600
});
const r = await fetch(API + '/shots?' + params);
const d = await r.json();
if (!d.shots || d.shots.length === 0) return;
const chart = document.getElementById('waveformChart');
if (!chart || !chart.layout) return;
// Red dots at bottom of chart
const yMin = chart.layout.yaxis && chart.layout.yaxis.range ? chart.layout.yaxis.range[0] : 0;
const shotTrace = {
x: d.shots.map(function(s){return s.offset_s}),
y: d.shots.map(function(){return yMin}),
type: 'scattergl', mode: 'markers',
marker: { color: 'red', size: 10, symbol: 'triangle-up' },
name: 'Shots (' + d.count + ')',
text: d.shots.map(function(s){return 'Shot L' + s.line + ' @ ' + s.time_str + ' (' + s.date + ')'}),
hovertemplate: '%{text}<extra></extra>'
};
// Vertical dashed red lines
var shapes = d.shots.map(function(s){return {
type: 'line', x0: s.offset_s, x1: s.offset_s,
y0: 0, y1: 1, yref: 'paper',
line: { color: 'rgba(255,50,50,0.25)', width: 1, dash: 'dot' }
}});
Plotly.addTraces(chart, [shotTrace]);
Plotly.relayout(chart, { shapes: shapes });
} catch(e) { console.warn('Shot overlay error:', e); }
}
// === Shot dots on Gather canvas ===
async function drawShotDots(canvas, ctx, ml, mr, mb, pw, gatherData) {
if (!gatherData || !gatherData.window_start || !gatherData.traces || !gatherData.traces.length) return;
var timeAxis = gatherData.time_axis;
if (!timeAxis || timeAxis.length < 2) return;
var tMin = timeAxis[0];
var tMax = timeAxis[timeAxis.length - 1];
var winStartUnix = gatherData.window_start;
var duration = tMax - tMin;
try {
// Use time-based API: pass absolute unix timestamps
var params = new URLSearchParams({
start_unix: winStartUnix,
duration: duration
});
var r = await fetch(API + '/shots_by_time?' + params);
var d = await r.json();
if (!d.shots || d.shots.length === 0) return;
var canvasH = canvas.height;
var yDot = canvasH - mb - 8;
ctx.save();
d.shots.forEach(function(shot) {
// shot.offset_s is seconds from winStartUnix
var tInWindow = shot.offset_s;
if (tInWindow < tMin || tInWindow > tMax) return;
var xFrac = (tInWindow - tMin) / (tMax - tMin);
var x = ml + xFrac * pw;
ctx.beginPath();
ctx.arc(x, yDot, 4, 0, 2 * Math.PI);
ctx.fillStyle = 'rgba(255, 40, 40, 0.85)';
ctx.fill();
ctx.strokeStyle = 'rgba(255, 100, 100, 0.5)';
ctx.lineWidth = 1;
ctx.stroke();
});
ctx.fillStyle = 'rgba(255, 40, 40, 0.8)';
ctx.font = '9px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('\u25cf ' + d.count + ' shots', ml + 2, yDot - 8);
ctx.restore();
} catch(e) { console.warn('Shot dots error:', e); }
}
// Shot overlay on gather: red dots at X=time, Y=crossline position
async function drawGatherShots(canvas, ctx, traces, timeAxis, startSec, durationSec, epochStart) {
if (!window.shotTimesCache) {
try {
var r = await fetch(API.replace('/api','') + '/shot_times.json');
window.shotTimesCache = await r.json();
} catch(e) { console.warn('No shot_times.json'); return; }
}
var shots = window.shotTimesCache;
if (!shots || !shots.length) return;
// Get receiver positions for Y mapping
// traces are already sorted, each has board_id -> darfNodes -> preplot_e, preplot_n
var tracePositions = [];
traces.forEach(function(t, i) {
var node = darfNodes[t.board_id];
if (node) {
tracePositions.push({
idx: i,
e: node.preplot_e,
n: node.preplot_n,
point: node.point || 0,
line: node.line || 0
});
}
});
if (tracePositions.length < 2) return;
// Receiver line direction vector (from first to last point)
var p0 = tracePositions[0];
var pN = tracePositions[tracePositions.length - 1];
var lineVecE = pN.e - p0.e;
var lineVecN = pN.n - p0.n;
var lineLen = Math.sqrt(lineVecE*lineVecE + lineVecN*lineVecN);
if (lineLen === 0) return;
// Normalize
var uE = lineVecE / lineLen;
var uN = lineVecN / lineLen;
// Y range on canvas: from trace 0 to trace N
var spacing = parseInt(document.getElementById('gatherSpacing').value) || 60;
var marginTop = 60;
var marginLeft = 80;
var canvasW = canvas.width;
var canvasH = canvas.height;
var traceCount = traces.length;
// Project each shot onto the receiver line to get Y position
// Shot northing needs +4000000 correction
var NORTH_OFFSET = 4000000;
// Time window: shots match by time-of-day
// epochStart is the absolute unix time of window start
// We need to find shots whose time_s (seconds since midnight) falls in our display window
var windowStartDate = new Date(epochStart * 1000);
var dayStartSec = windowStartDate.getUTCHours() * 3600 + windowStartDate.getUTCMinutes() * 60 + windowStartDate.getUTCSeconds();
var displayStartTOD = dayStartSec + startSec; // time-of-day at display start
var displayEndTOD = displayStartTOD + durationSec;
// Draw
ctx.save();
var dotCount = 0;
shots.forEach(function(shot) {
// Check time match (time_s is seconds since midnight)
var shotTOD = shot.time_s;
// Handle wrap around midnight
if (shotTOD < displayStartTOD - 86400) shotTOD += 86400;
if (shotTOD > displayEndTOD + 86400) return;
if (shotTOD < displayStartTOD || shotTOD > displayEndTOD) return;
// X position on canvas
var xFrac = (shotTOD - displayStartTOD) / durationSec;
var x = marginLeft + xFrac * (canvasW - marginLeft - 20);
// Y position: project shot onto receiver line
var shotE = shot.easting;
var shotN = shot.northing + NORTH_OFFSET;
// Project onto line: t = dot(shot - p0, u)
var dE = shotE - p0.e;
var dN = shotN - p0.n;
var proj = dE * uE + dN * uN;
var yFrac = proj / lineLen; // 0 = first trace, 1 = last trace
// Clamp but allow slight outside for nearby shots
if (yFrac < -0.1 || yFrac > 1.1) return;
yFrac = Math.max(0, Math.min(1, yFrac));
var y = marginTop + yFrac * (traceCount - 1) * spacing;
// Also compute crossline distance for opacity
var crossDist = Math.abs(dE * (-uN) + dN * uE); // perpendicular distance
var maxCrossDist = 500; // meters - shots beyond this are too far
if (crossDist > maxCrossDist) return;
var opacity = 1.0 - (crossDist / maxCrossDist) * 0.7;
// Draw dot
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255, 50, 50, ' + opacity.toFixed(2) + ')';
ctx.fill();
dotCount++;
});
// Legend
if (dotCount > 0) {
ctx.fillStyle = 'rgba(255, 50, 50, 0.9)';
ctx.font = '11px monospace';
ctx.fillText('● ' + dotCount + ' shots', canvasW - 120, 15);
}
ctx.restore();
}
function extractGather() {
if (gatherSelectedFiles.size === 0) {
setInfo('Extract: select files first');
return;
}
// Populate modal with current values
document.getElementById('extStart').value = document.getElementById('gatherStart').value;
document.getElementById('extDuration').value = document.getElementById('gatherDuration').value;
// Update scope option text with counts
var currentCount = 0;
if (gatherOverlapInfo && gatherOverlapInfo.groups && gatherOverlapInfo.groups[gatherActiveGroup]) {
currentCount = gatherOverlapInfo.groups[gatherActiveGroup].files.length;
} else {
currentCount = gatherSelectedFiles.size;
}
var lineDropdown = document.getElementById('gatherLineDropdown');
var currentLine = lineDropdown ? lineDropdown.value : '';
var scopeEl = document.getElementById('extScope');
scopeEl.options[0].text = 'Current selection (' + currentCount + ' files)';
if (currentLine) {
scopeEl.options[1].text = 'All files on ' + currentLine + ' line';
}
scopeEl.options[2].text = 'All 8 lines (' + files.filter(f=>f.name.endsWith(".h5")).length + ' files)';
updateExtEstimate();
document.getElementById('extractModal').style.display = 'flex';
}
function closeExtractModal() {
document.getElementById('extractModal').style.display = 'none';
}
function updateExtEstimate() {
var channels = document.getElementById('extChannels').value;
var scope = document.getElementById('extScope').value;
var duration = parseFloat(document.getElementById('extDuration').value) || 60;
var nCh = 8;
if (channels === 'all_cal' || channels === 'all_raw') nCh = 4;
else if (channels.startsWith('cal_') || channels.startsWith('raw_')) nCh = 1;
var nFiles = gatherSelectedFiles.size;
if (scope === 'all_lines') nFiles = files.filter(f=>f.name.endsWith('.h5')).length;
var samples = duration * 500 * nFiles;
var estMB = (samples * nCh * 10) / 1e6; // ~10 bytes per value in CSV
var estZipMB = estMB * 0.3; // ZIP compression ~70%
document.getElementById('extEstimate').innerHTML =
'<b>' + nFiles + '</b> files × <b>' + nCh + '</b> ch × <b>' + duration + '</b>s @ 500Hz<br>' +
'Samples: <b>' + (samples).toLocaleString() + '</b> per channel<br>' +
'Estimated ZIP: ~<b>' + Math.round(estZipMB) + ' MB</b> (CSV ~' + Math.round(estMB) + ' MB uncompressed)<br>' +
'<span style="color:var(--green)">⚡ Full resolution — zero decimation</span>';
}
async function doExtract() {
var channels = document.getElementById('extChannels').value;
var scope = document.getElementById('extScope').value;
var start = parseFloat(document.getElementById('extStart').value);
var duration = parseFloat(document.getElementById('extDuration').value);
var incNodes = document.getElementById('extIncNodes').checked;
var incShots = document.getElementById('extIncShots').checked;
var incMeta = document.getElementById('extIncMeta').checked;
// Determine files
var filesParam;
if (scope === 'all_lines') {
filesParam = files.filter(f=>f.name.endsWith('.h5')).map(f=>f.name).join(',');
} else if (scope === 'line') {
// All files on current line from sidebar
var lineDropdown = document.getElementById('gatherLineDropdown');
var currentLine = lineDropdown ? lineDropdown.value : '';
if (currentLine) {
var lineNum = parseInt(currentLine.replace('Line ',''));
var lineFiles = [];
files.forEach(function(f) {
var ni = getNodeInfo(f.name);
if (ni && ni.line === lineNum) lineFiles.push(f.name);
});
filesParam = lineFiles.join(',');
} else {
filesParam = Array.from(gatherSelectedFiles).join(',');
}
} else {
// Current selection (use group if available)
if (gatherOverlapInfo && gatherOverlapInfo.groups && gatherOverlapInfo.groups[gatherActiveGroup]) {
filesParam = gatherOverlapInfo.groups[gatherActiveGroup].files.join(',');
} else {
filesParam = Array.from(gatherSelectedFiles).join(',');
}
}
closeExtractModal();
setInfo('📥 Extracting... this may take a while.');
try {
var r = await fetch(API + '/gather_extract', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
files: filesParam, start: start, duration: duration,
channels: channels,
include_nodes: incNodes,
include_shots: incShots,
include_metadata: incMeta
})
});
if (!r.ok) {
var err = await r.json();
setInfo('Extract error: ' + (err.error || r.statusText));
return;
}
var taskData = await r.json();
if (!taskData.task_id) {
setInfo('Extract error: no task_id returned');
return;
}
var taskId = taskData.task_id;
setInfo('📥 Processing extract task...');
// Poll until done
while (true) {
await new Promise(function(resolve) { setTimeout(resolve, 2000); });
var sr = await fetch(API + '/tasks/' + taskId);
var sd = await sr.json();
if (sd.status === 'completed') {
setInfo('📥 Downloading ZIP...');
var dr = await fetch(API + '/tasks/' + taskId + '/download');
var blob = await dr.blob();
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'gather_extract_s' + Math.round(start) + '_d' + Math.round(duration) + '.zip';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setInfo('📥 Downloaded: ' + (blob.size / 1e6).toFixed(1) + ' MB');
break;
} else if (sd.status === 'error') {
setInfo('Extract error: ' + (sd.error || 'unknown'));
break;
} else {
setInfo('📥 Processing... ' + (sd.progress || ''));
}
}
} catch(e) {
setInfo('Extract error: ' + e.message);
}
}
// Update estimate when options change
document.addEventListener('DOMContentLoaded', function() {
['extChannels','extScope','extDuration'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.addEventListener('change', updateExtEstimate);
if (el) el.addEventListener('input', updateExtEstimate);
});
});
</script>
<!-- Extract Modal -->
<div id="extractModal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:9999;align-items:center;justify-content:center">
<div style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:24px;max-width:480px;width:90%;color:var(--text);box-shadow:0 8px 32px rgba(0,0,0,0.5)">
<h3 style="margin:0 0 16px;color:var(--accent)">📥 Gather Extract — Full Resolution</h3>
<div style="margin-bottom:12px">
<label style="font-weight:600;font-size:13px;display:block;margin-bottom:4px">📡 Channels</label>
<select id="extChannels" style="width:100%;padding:6px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px">
<option value="all8">All 8 channels (4 calibrated + 4 raw ADC)</option>
<option value="all_cal">4 calibrated channels</option>
<option value="all_raw">4 raw ADC channels</option>
<option value="cal_1">Calibrated channel 1 only</option>
<option value="cal_2">Calibrated channel 2 only</option>
<option value="cal_3">Calibrated channel 3 only</option>
<option value="cal_4">Calibrated channel 4 only</option>
<option value="raw_1">Raw ADC channel 1 only</option>
<option value="raw_2">Raw ADC channel 2 only</option>
<option value="raw_3">Raw ADC channel 3 only</option>
<option value="raw_4">Raw ADC channel 4 only</option>
</select>
</div>
<div style="margin-bottom:12px">
<label style="font-weight:600;font-size:13px;display:block;margin-bottom:4px">📐 Scope</label>
<select id="extScope" style="width:100%;padding:6px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px">
<option value="current">Current selection (23 files)</option>
<option value="line">All files on current line</option>
<option value="all_lines">All 8 lines (167 files)</option>
</select>
</div>
<div style="margin-bottom:12px">
<label style="font-weight:600;font-size:13px;display:block;margin-bottom:4px">⏱ Time Window</label>
<div style="display:flex;gap:8px">
<div style="flex:1"><label style="font-size:11px;color:var(--muted)">Start (s)</label><input type="number" id="extStart" style="width:100%;padding:4px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px"></div>
<div style="flex:1"><label style="font-size:11px;color:var(--muted)">Duration (s)</label><input type="number" id="extDuration" style="width:100%;padding:4px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px"></div>
</div>
</div>
<div style="margin-bottom:16px">
<label style="font-weight:600;font-size:13px;display:block;margin-bottom:6px">📎 Include</label>
<label style="display:block;font-size:12px;margin-bottom:4px;cursor:pointer"><input type="checkbox" id="extIncNodes" checked> Node positions (DARF deployment data)</label>
<label style="display:block;font-size:12px;margin-bottom:4px;cursor:pointer"><input type="checkbox" id="extIncShots" checked> SPS shot points (matching time window)</label>
<label style="display:block;font-size:12px;cursor:pointer"><input type="checkbox" id="extIncMeta" checked> Metadata JSON</label>
</div>
<div id="extEstimate" style="background:var(--bg);padding:8px;border-radius:4px;font-size:12px;margin-bottom:16px;color:var(--muted)">
Estimating...
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="closeExtractModal()" style="padding:8px 16px;background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:4px;cursor:pointer">Cancel</button>
<button onclick="doExtract()" style="padding:8px 16px;background:var(--accent);border:none;color:#fff;border-radius:4px;cursor:pointer;font-weight:600">📥 Download ZIP</button>
</div>
</div>
</div>
</body>
</html>