2030 lines
89 KiB
Plaintext
2030 lines
89 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="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>
|
||
</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;
|
||
|
||
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('/darf_nodes.json');
|
||
darfNodes = await dr.json();
|
||
populateGatherLineDropdown();
|
||
} catch (e) {
|
||
console.error('Failed to load DARF nodes:', e);
|
||
}
|
||
}
|
||
|
||
function renderFiles(list) {
|
||
const showCb = currentTab === 'gather';
|
||
document.getElementById('fileList').innerHTML = list.map(f => {
|
||
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)">` : '';
|
||
return `<div class="fitem${selectedFile===f.name?' active':''}" onclick="selectFile('${escapedName}')">
|
||
<div class="fhead">
|
||
${cb}<span class="fn" title="${f.name}">${f.name}</span>
|
||
<span class="ft ft-${f.type}">${f.type.toUpperCase()}</span>
|
||
</div>
|
||
${infoText}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function filterFiles() {
|
||
const q = document.getElementById('searchInput').value.toLowerCase();
|
||
renderFiles(files.filter(f => f.name.toLowerCase().includes(q)));
|
||
}
|
||
|
||
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);
|
||
}, 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 });
|
||
} 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 };
|
||
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 (!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);
|
||
|
||
document.getElementById('layerSources').checked = true;
|
||
document.getElementById('layerReceivers').checked = true;
|
||
document.getElementById('layerDeployments').checked = true;
|
||
layerVisibility.sources = true;
|
||
layerVisibility.receivers = true;
|
||
layerVisibility.deployments = true;
|
||
|
||
geosupLayers.sources.addTo(map);
|
||
geosupLayers.receivers.addTo(map);
|
||
geosupLayers.deployments.addTo(map);
|
||
|
||
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) {
|
||
const checkbox = document.getElementById('layer' + layerName.charAt(0).toUpperCase() + layerName.slice(1));
|
||
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;
|
||
}
|
||
|
||
async function loadGather() {
|
||
if (gatherSelectedFiles.size === 0) {
|
||
setInfo('Gather: select files first');
|
||
return;
|
||
}
|
||
setInfo('Loading gather data...');
|
||
document.getElementById('gatherEmpty').style.display = 'flex';
|
||
document.getElementById('gatherEmpty').textContent = 'Loading...';
|
||
|
||
const channel = document.getElementById('gatherChannel').value;
|
||
const start = parseFloat(document.getElementById('gatherStart').value);
|
||
const duration = parseFloat(document.getElementById('gatherDuration').value);
|
||
const filesParam = Array.from(gatherSelectedFiles).join(',');
|
||
|
||
try {
|
||
const r = await fetch(`${API}/gather?files=${encodeURIComponent(filesParam)}&channel=${encodeURIComponent(channel)}&start=${start}&duration=${duration}&points=2000`);
|
||
const d = await r.json();
|
||
if (d.error) {
|
||
document.getElementById('gatherEmpty').textContent = 'Error: ' + d.error;
|
||
return;
|
||
}
|
||
gatherData = d;
|
||
document.getElementById('gatherEmpty').style.display = 'none';
|
||
// Update slider max from actual file duration if available
|
||
if (d.file_duration) updateGatherSliderMax(d.file_duration);
|
||
setInfo(`Gather: ${d.traces.length} traces loaded`);
|
||
renderGather();
|
||
} catch (e) {
|
||
document.getElementById('gatherEmpty').textContent = 'Error: ' + e.message;
|
||
setInfo('Gather error: ' + e.message);
|
||
}
|
||
}
|
||
|
||
function renderGather() {
|
||
if (!gatherData || !gatherData.traces.length) return;
|
||
|
||
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 = [...gatherData.traces];
|
||
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;
|
||
const absT = t + parseFloat(document.getElementById('gatherStart').value || 0);
|
||
ctx.fillText(fmtTime(absT), 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';
|
||
ctx.fillText(`Gather: ${nTraces} traces, ${mode} mode`, 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);
|
||
}
|
||
|
||
init();
|
||
|
||
// 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>
|
||
</body>
|
||
</html>
|