2683 lines
118 KiB
Plaintext
2683 lines
118 KiB
Plaintext
<!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.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}\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 blob = await r.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');
|
||
} 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>
|