Fix coverage: add /api/coverage route, remove stray gather code from loadCoverage

This commit is contained in:
Floppyrj45
2026-02-19 14:53:10 +01:00
parent 61b25ab734
commit bbd6a22b57
80 changed files with 27884 additions and 1 deletions

View File

@@ -0,0 +1,241 @@
import React, { useState, useEffect } from 'react';
interface TimelineEvent {
date: string;
event: string;
type: 'start' | 'data' | 'milestone' | 'operation' | 'end';
}
interface Document {
name: string;
file: string;
category: string;
}
interface CampaignManifest {
timeline: TimelineEvent[];
documents: Document[];
}
export default function CampaignDocs() {
const [manifest, setManifest] = useState<CampaignManifest | null>(null);
const [activeTab, setActiveTab] = useState<'timeline' | 'docs'>('timeline');
useEffect(() => {
fetch('/seismic/api/docs/manifest')
.then(r => r.json())
.then(setManifest)
.catch(console.error);
}, []);
if (!manifest) return <div style={{ padding: '20px', color: '#64748b' }}>Chargement...</div>;
const typeColors: Record<TimelineEvent['type'], string> = {
start: '#10b981',
data: '#3b82f6',
milestone: '#f59e0b',
operation: '#8b5cf6',
end: '#ef4444'
};
const typeIcons: Record<TimelineEvent['type'], string> = {
start: '🚀',
data: '📊',
milestone: '🎯',
operation: '⚙️',
end: '🏁'
};
return (
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: '30px' }}>
<h1 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#1e293b', marginBottom: '10px' }}>
📚 Campagne SeaKESP - Sète 2020
</h1>
<p style={{ color: '#64748b', fontSize: '1.1rem' }}>
Documentation, chronologie et ressources de la campagne OBN
</p>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px', borderBottom: '2px solid #e2e8f0' }}>
<button
onClick={() => setActiveTab('timeline')}
style={{
padding: '10px 20px',
background: activeTab === 'timeline' ? '#3b82f6' : 'transparent',
color: activeTab === 'timeline' ? 'white' : '#64748b',
border: 'none',
borderRadius: '8px 8px 0 0',
cursor: 'pointer',
fontWeight: 'bold',
fontSize: '1rem'
}}
>
📅 Chronologie
</button>
<button
onClick={() => setActiveTab('docs')}
style={{
padding: '10px 20px',
background: activeTab === 'docs' ? '#3b82f6' : 'transparent',
color: activeTab === 'docs' ? 'white' : '#64748b',
border: 'none',
borderRadius: '8px 8px 0 0',
cursor: 'pointer',
fontWeight: 'bold',
fontSize: '1rem'
}}
>
📄 Documents
</button>
</div>
{/* Timeline Tab */}
{activeTab === 'timeline' && (
<div style={{ background: 'white', padding: '30px', borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#1e293b', marginBottom: '20px' }}>
Chronologie de la campagne
</h2>
<div style={{ position: 'relative', paddingLeft: '40px' }}>
{/* Timeline line */}
<div style={{
position: 'absolute',
left: '15px',
top: '10px',
bottom: '10px',
width: '3px',
background: 'linear-gradient(to bottom, #10b981, #ef4444)'
}} />
{manifest.timeline.map((event, idx) => (
<div key={idx} style={{ position: 'relative', marginBottom: '25px' }}>
{/* Timeline dot */}
<div style={{
position: 'absolute',
left: '-30px',
width: '15px',
height: '15px',
borderRadius: '50%',
background: typeColors[event.type],
border: '3px solid white',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)'
}} />
{/* Event card */}
<div style={{
background: '#f8fafc',
padding: '15px',
borderRadius: '8px',
border: `2px solid ${typeColors[event.type]}`,
marginLeft: '10px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '5px' }}>
<span style={{ fontSize: '1.5rem' }}>{typeIcons[event.type]}</span>
<span style={{ fontSize: '0.9rem', fontWeight: 'bold', color: typeColors[event.type] }}>
{event.date}
</span>
</div>
<p style={{ fontSize: '1rem', color: '#334155', margin: 0 }}>
{event.event}
</p>
</div>
</div>
))}
</div>
{/* Stats */}
<div style={{ marginTop: '30px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '15px' }}>
<div style={{ background: '#f0f9ff', padding: '15px', borderRadius: '8px', textAlign: 'center' }}>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#3b82f6' }}>46 jours</div>
<div style={{ color: '#64748b', fontSize: '0.9rem' }}>Durée totale</div>
</div>
<div style={{ background: '#f0fdf4', padding: '15px', borderRadius: '8px', textAlign: 'center' }}>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#10b981' }}>345 fichiers</div>
<div style={{ color: '#64748b', fontSize: '0.9rem' }}>RAW collectés</div>
</div>
<div style={{ background: '#fef3c7', padding: '15px', borderRadius: '8px', textAlign: 'center' }}>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#f59e0b' }}>~800h</div>
<div style={{ color: '#64748b', fontSize: '0.9rem' }}>Enregistrements</div>
</div>
</div>
</div>
)}
{/* Documents Tab */}
{activeTab === 'docs' && (
<div style={{ background: 'white', padding: '30px', borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#1e293b', marginBottom: '20px' }}>
Documents de référence
</h2>
{['Sources', 'Acquisition', 'Specifications', 'Geometry', 'Maps'].map(category => {
const categoryDocs = manifest.documents.filter(d => d.category === category);
if (categoryDocs.length === 0) return null;
const categoryIcons: Record<string, string> = {
Sources: '🎺',
Acquisition: '📋',
Specifications: '📖',
Geometry: '🗺️',
Maps: '🗺️'
};
return (
<div key={category} style={{ marginBottom: '25px' }}>
<h3 style={{ fontSize: '1.2rem', fontWeight: 'bold', color: '#475569', marginBottom: '10px', display: 'flex', alignItems: 'center', gap: '10px' }}>
<span>{categoryIcons[category]}</span>
{category}
</h3>
<div style={{ display: 'grid', gap: '10px' }}>
{categoryDocs.map((doc, idx) => (
<a
key={idx}
href={`/seismic/api/docs/${doc.file}`}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'flex',
alignItems: 'center',
gap: '15px',
padding: '15px',
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: '8px',
textDecoration: 'none',
color: '#334155',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f1f5f9';
e.currentTarget.style.borderColor = '#3b82f6';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#f8fafc';
e.currentTarget.style.borderColor = '#e2e8f0';
}}
>
<div style={{ fontSize: '1.5rem' }}>
{doc.file.endsWith('.pdf') ? '📄' :
doc.file.endsWith('.xlsx') || doc.file.endsWith('.xlsm') ? '📊' :
doc.file.endsWith('.docx') ? '📝' :
doc.file.endsWith('.png') ? '🖼️' : '📁'}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 'bold', marginBottom: '3px' }}>{doc.name}</div>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>{doc.file}</div>
</div>
<div style={{ color: '#3b82f6', fontSize: '1.2rem' }}></div>
</a>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,206 @@
import React, { useState } from 'react';
interface Props {
onClose: () => void;
}
export default function DataDocumentation({ onClose }: Props) {
const [activeTab, setActiveTab] = useState<'overview' | 'channels' | 'pipeline' | 'conversion' | 'format'>('overview');
const tabs = [
{ id: 'overview' as const, label: 'Vue d\'ensemble', icon: '📋' },
{ id: 'channels' as const, label: 'Canaux', icon: '📊' },
{ id: 'pipeline' as const, label: 'Pipeline', icon: '🔄' },
{ id: 'conversion' as const, label: 'Conversion', icon: '⚙️' },
{ id: 'format' as const, label: 'Format H5', icon: '📦' }
];
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', zIndex: 10000, display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '20px' }}>
<div style={{ background: 'white', borderRadius: '12px', maxWidth: '900px', width: '100%', maxHeight: '90vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{/* Header */}
<div style={{ padding: '20px', borderBottom: '2px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#1e293b', margin: 0 }}>
Documentation Technique
</h2>
<button onClick={onClose} style={{ background: '#ef4444', color: 'white', border: 'none', padding: '8px 16px', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}>
Fermer
</button>
</div>
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '2px solid #e2e8f0', overflowX: 'auto' }}>
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
style={{
padding: '12px 20px',
background: activeTab === tab.id ? '#3b82f6' : 'transparent',
color: activeTab === tab.id ? 'white' : '#64748b',
border: 'none',
borderBottom: activeTab === tab.id ? '3px solid #2563eb' : '3px solid transparent',
cursor: 'pointer',
fontWeight: activeTab === tab.id ? 'bold' : 'normal',
fontSize: '0.9rem',
whiteSpace: 'nowrap'
}}
>
{tab.icon} {tab.label}
</button>
))}
</div>
{/* Content */}
<div style={{ flex: 1, overflowY: 'auto', padding: '30px' }}>
{activeTab === 'overview' && (
<div>
<h3 style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#1e293b', marginBottom: '15px' }}>Campagne SeaKESP - Sète 2020</h3>
<p style={{ fontSize: '1rem', color: '#475569', marginBottom: '20px' }}>
Campagne d'acquisition sismique Ocean Bottom Node (OBN) réalisée en août-septembre 2020.
</p>
<h4 style={{ fontSize: '1.1rem', fontWeight: 'bold', color: '#334155', marginTop: '20px', marginBottom: '10px' }}>Données acquises</h4>
<ul style={{ color: '#475569', lineHeight: '1.8' }}>
<li><strong>345 fichiers RAW</strong> propriétaires (2.7 TB)</li>
<li><strong>Durée totale :</strong> ~800 heures d'enregistrements continus</li>
<li><strong>Plus long fichier :</strong> 60 heures (217k secondes)</li>
<li><strong>Fréquence d'échantillonnage :</strong> 500 Hz</li>
</ul>
<h4 style={{ fontSize: '1.1rem', fontWeight: 'bold', color: '#334155', marginTop: '20px', marginBottom: '10px' }}>État de conversion</h4>
<p style={{ color: '#475569' }}>
Conversion RAW → H5 en cours sur VM .81 (4 workers parallèles).
</p>
</div>
)}
{activeTab === 'channels' && (
<div>
<h3 style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#1e293b', marginBottom: '15px' }}>Canaux d'acquisition</h3>
<div style={{ marginBottom: '25px' }}>
<h4 style={{ fontSize: '1.1rem', fontWeight: 'bold', color: '#0369a1', marginBottom: '10px' }}>Géophones (Canaux 1-3)</h4>
<p style={{ color: '#475569', marginBottom: '10px' }}>Capteurs de vitesse particulaire 3 composantes (X, Y, Z)</p>
<ul style={{ color: '#475569', lineHeight: '1.8' }}>
<li><strong>Type :</strong> Géophones 15.6 V/(m/s)</li>
<li><strong>Unité finale :</strong> m/s (mètres par seconde)</li>
<li><strong>Sensibilité ADC :</strong> 3.576e-7 V/bit</li>
<li><strong>Calibration :</strong> m/s = (ADC × 3.576e-7) / 15.6</li>
</ul>
</div>
<div>
<h4 style={{ fontSize: '1.1rem', fontWeight: 'bold', color: '#7c2d12', marginBottom: '10px' }}>Hydrophone (Canal 4)</h4>
<p style={{ color: '#475569', marginBottom: '10px' }}>Capteur de pression acoustique</p>
<ul style={{ color: '#475569', lineHeight: '1.8' }}>
<li><strong>Type :</strong> Hydrophone 8.9 V/bar</li>
<li><strong>Unité finale :</strong> Pa (Pascals)</li>
<li><strong>Sensibilité ADC :</strong> 2.841e-6 V/bit</li>
<li><strong>Calibration :</strong> Pa = (ADC × 2.841e-6 / 8.9) × 100000</li>
<li><strong>Note :</strong> 1 bar = 100000 Pa</li>
</ul>
</div>
</div>
)}
{activeTab === 'pipeline' && (
<div>
<h3 style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#1e293b', marginBottom: '15px' }}>Pipeline de traitement</h3>
<div style={{ background: '#f8fafc', padding: '20px', borderRadius: '8px', marginBottom: '20px' }}>
<h4 style={{ fontSize: '1rem', fontWeight: 'bold', color: '#475569', marginBottom: '15px' }}>Étapes de conversion</h4>
<ol style={{ color: '#475569', lineHeight: '2', paddingLeft: '20px' }}>
<li><strong>Fichier source :</strong> RAW propriétaire (format binaire non documenté)</li>
<li><strong>Conversion initiale :</strong> RAW SEGY via MantaSegy</li>
<li><strong>Parsing SEGY :</strong> Extraction headers + données ADC brutes</li>
<li><strong>Calibration :</strong> Application formules ADC unités physiques</li>
<li><strong>Export H5 :</strong> Sauvegarde données brutes + calibrées</li>
<li><strong>Validation :</strong> Vérification précision (erreur &lt; 10¹¹)</li>
</ol>
</div>
<h4 style={{ fontSize: '1.1rem', fontWeight: 'bold', color: '#334155', marginTop: '20px', marginBottom: '10px' }}>Objectif du traitement</h4>
<p style={{ color: '#475569' }}>Convertir les fichiers RAW propriétaires en format HDF5 standardisé avec :</p>
<ul style={{ color: '#475569', lineHeight: '1.8' }}>
<li>Données ADC brutes (int32) pour archivage</li>
<li>Données calibrées en unités physiques (float32)</li>
<li>Métadonnées complètes (sample rate, durée, calibration)</li>
</ul>
</div>
)}
{activeTab === 'conversion' && (
<div>
<h3 style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#1e293b', marginBottom: '15px' }}>Détails de conversion</h3>
<h4 style={{ fontSize: '1.1rem', fontWeight: 'bold', color: '#334155', marginBottom: '10px' }}>Configuration actuelle</h4>
<ul style={{ color: '#475569', lineHeight: '1.8' }}>
<li><strong>Machine :</strong> VM .81 (osboxes@192.168.0.81)</li>
<li><strong>Workers :</strong> 4 processus parallèles</li>
<li><strong>Stockage source :</strong> NFS depuis Pi 52 (/mnt/seismic-data)</li>
<li><strong>Stockage destination :</strong> Local VM puis rsync vers Pi 27</li>
</ul>
<h4 style={{ fontSize: '1.1rem', fontWeight: 'bold', color: '#334155', marginTop: '20px', marginBottom: '10px' }}>Formules de calibration</h4>
<div style={{ background: '#f1f5f9', padding: '15px', borderRadius: '8px', fontFamily: 'monospace', fontSize: '0.9rem' }}>
<p style={{ color: '#0369a1', marginBottom: '10px' }}><strong>Géophones (canaux 1-3) :</strong></p>
<p style={{ color: '#475569' }}>m/s = (ADC_value × 3.576e-7 V/bit) / (15.6 V/(m/s))</p>
<p style={{ color: '#7c2d12', marginTop: '15px', marginBottom: '10px' }}><strong>Hydrophone (canal 4) :</strong></p>
<p style={{ color: '#475569' }}>Pa = (ADC_value × 2.841e-6 V/bit / 8.9 V/bar) × 100000 Pa/bar</p>
</div>
<h4 style={{ fontSize: '1.1rem', fontWeight: 'bold', color: '#334155', marginTop: '20px', marginBottom: '10px' }}>Validation qualité</h4>
<p style={{ color: '#475569' }}>
Précision de conversion vérifiée : erreur &lt; 10¹¹ entre calcul Python et valeurs attendues.
</p>
</div>
)}
{activeTab === 'format' && (
<div>
<h3 style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#1e293b', marginBottom: '15px' }}>Structure fichier H5</h3>
<div style={{ background: '#0f172a', padding: '20px', borderRadius: '8px', color: '#e2e8f0', fontFamily: 'monospace', fontSize: '0.85rem', marginBottom: '20px' }}>
<pre style={{ margin: 0 }}>{`/
├── metadata (group)
│ ├── @duration_sec: 20.0
│ ├── @sample_rate_hz: 500
│ ├── @n_channels: 4
│ └── @n_samples: 10000
├── calibration (group)
│ ├── @geophone_v_per_bit: 3.576e-7
│ ├── @geophone_v_per_ms: 15.6
│ ├── @hydrophone_v_per_bit: 2.841e-6
│ └── @hydrophone_v_per_bar: 8.9
├── raw_data (group)
│ ├── channel_1 (dataset int32)
│ ├── channel_2 (dataset int32)
│ ├── channel_3 (dataset int32)
│ └── channel_4 (dataset int32)
└── calibrated_data (group)
├── channel_1 (dataset float32, unit: m/s)
├── channel_2 (dataset float32, unit: m/s)
├── channel_3 (dataset float32, unit: m/s)
└── channel_4 (dataset float32, unit: Pa)`}</pre>
</div>
<h4 style={{ fontSize: '1.1rem', fontWeight: 'bold', color: '#334155', marginBottom: '10px' }}>Avantages du format H5</h4>
<ul style={{ color: '#475569', lineHeight: '1.8' }}>
<li><strong>Compression efficace :</strong> Réduction ~30% taille fichiers</li>
<li><strong>Accès rapide :</strong> Lecture sélective par canal/plage temporelle</li>
<li><strong>Métadonnées intégrées :</strong> Toutes les infos de calibration dans le fichier</li>
<li><strong>Interopérabilité :</strong> Compatible Python, MATLAB, Julia, C++</li>
<li><strong>Archivage :</strong> Données brutes + calibrées dans un seul fichier</li>
</ul>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
import { useState, useEffect } from 'react';
const API_BASE = '/seismic/api';
interface CoverageStats {
total_positions: number;
with_data: number;
with_aux: number;
total_files: number;
coverage_pct: number;
missing: number;
}
interface Gap {
start: number;
end: number;
length: number;
}
function H5Coverage({ onClose }: { onClose: () => void }) {
const [stats, setStats] = useState<CoverageStats | null>(null);
const [gaps, setGaps] = useState<Gap[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
fetch(`${API_BASE}/h5/coverage`).then(r => r.json()),
fetch(`${API_BASE}/h5/gaps`).then(r => r.json())
]).then(([statsData, gapsData]) => {
setStats(statsData);
setGaps(gapsData.gaps.sort((a: Gap, b: Gap) => b.length - a.length));
setLoading(false);
});
}, []);
if (loading) {
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: '#0f172a', zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff' }}>
<div>Chargement des statistiques H5</div>
</div>
);
}
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: '#0f172a', zIndex: 3000, display: 'flex', flexDirection: 'column', overflowY: 'auto' }}>
<div style={{ padding: '20px', background: '#1e293b', borderBottom: '1px solid #334155', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, color: '#f8fafc' }}>📊 H5 Data Coverage</h2>
<button onClick={onClose} style={{ background: '#ef4444', color: '#fff', border: 'none', padding: '8px 20px', borderRadius: '4px', cursor: 'pointer' }}>Fermer</button>
</div>
<div style={{ flex: 1, padding: '20px', maxWidth: '1200px', margin: '0 auto', width: '100%' }}>
{/* Stats globales */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '15px', marginBottom: '30px' }}>
<div style={{ background: '#1e293b', padding: '20px', borderRadius: '8px', border: '1px solid #334155' }}>
<div style={{ fontSize: '0.8rem', color: '#94a3b8', marginBottom: '5px' }}>Positions totales</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#f8fafc' }}>{stats?.total_positions}</div>
</div>
<div style={{ background: '#1e293b', padding: '20px', borderRadius: '8px', border: '1px solid #334155' }}>
<div style={{ fontSize: '0.8rem', color: '#94a3b8', marginBottom: '5px' }}>Avec données</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#4ade80' }}>{stats?.with_data}</div>
</div>
<div style={{ background: '#1e293b', padding: '20px', borderRadius: '8px', border: '1px solid #334155' }}>
<div style={{ fontSize: '0.8rem', color: '#94a3b8', marginBottom: '5px' }}>Manquantes</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#ef4444' }}>{stats?.missing}</div>
</div>
<div style={{ background: '#1e293b', padding: '20px', borderRadius: '8px', border: '1px solid #334155' }}>
<div style={{ fontSize: '0.8rem', color: '#94a3b8', marginBottom: '5px' }}>Coverage</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: stats && stats.coverage_pct > 50 ? '#4ade80' : '#fbbf24' }}>{stats?.coverage_pct}%</div>
</div>
<div style={{ background: '#1e293b', padding: '20px', borderRadius: '8px', border: '1px solid #334155' }}>
<div style={{ fontSize: '0.8rem', color: '#94a3b8', marginBottom: '5px' }}>Fichiers totaux</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#38bdf8' }}>{stats?.total_files}</div>
</div>
</div>
{/* Progress bar */}
<div style={{ background: '#1e293b', padding: '20px', borderRadius: '8px', border: '1px solid #334155', marginBottom: '30px' }}>
<div style={{ fontSize: '0.9rem', color: '#94a3b8', marginBottom: '10px' }}>Progression du déploiement</div>
<div style={{ background: '#0f172a', height: '30px', borderRadius: '15px', overflow: 'hidden', position: 'relative' }}>
<div style={{
background: 'linear-gradient(90deg, #4ade80, #22c55e)',
height: '100%',
width: `${stats?.coverage_pct}%`,
transition: 'width 1s ease'
}}></div>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', color: '#fff', fontWeight: 'bold', fontSize: '0.9rem' }}>
{stats?.with_data} / {stats?.total_positions} positions
</div>
</div>
</div>
{/* Top 10 gaps */}
<div style={{ background: '#1e293b', padding: '20px', borderRadius: '8px', border: '1px solid #334155' }}>
<h3 style={{ margin: '0 0 15px 0', color: '#f8fafc' }}>🔍 Top 10 Gaps (plages manquantes)</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{gaps.slice(0, 10).map((gap, i) => (
<div key={i} style={{ background: '#0f172a', padding: '12px', borderRadius: '6px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<span style={{ color: '#f8fafc', fontWeight: 'bold' }}>b{gap.start}</span>
<span style={{ color: '#94a3b8', margin: '0 8px' }}></span>
<span style={{ color: '#f8fafc', fontWeight: 'bold' }}>b{gap.end}</span>
</div>
<div style={{
background: gap.length > 50 ? '#dc2626' : gap.length > 20 ? '#f59e0b' : '#3b82f6',
color: '#fff',
padding: '4px 12px',
borderRadius: '12px',
fontSize: '0.85rem',
fontWeight: 'bold'
}}>
{gap.length} positions
</div>
</div>
))}
</div>
{gaps.length > 10 && (
<div style={{ marginTop: '15px', textAlign: 'center', color: '#94a3b8', fontSize: '0.85rem' }}>
et {gaps.length - 10} autres gaps
</div>
)}
</div>
{/* Verdict */}
<div style={{
background: stats && stats.coverage_pct < 50 ? '#7f1d1d' : '#065f46',
padding: '20px',
borderRadius: '8px',
marginTop: '30px',
border: stats && stats.coverage_pct < 50 ? '1px solid #dc2626' : '1px solid #10b981'
}}>
<div style={{ fontSize: '1.1rem', fontWeight: 'bold', color: '#fff', marginBottom: '10px' }}>
{stats && stats.coverage_pct < 50 ? '⚠️ Déploiement partiel détecté' : '✅ Coverage acceptable'}
</div>
<div style={{ color: '#e5e7eb', fontSize: '0.9rem' }}>
{stats && stats.coverage_pct < 50
? `Seulement ${stats.coverage_pct}% des positions planifiées ont des données. Cela suggère un test de déploiement plutôt qu'une collecte complète.`
: `${stats?.coverage_pct}% des positions déployées avec succès.`
}
</div>
</div>
</div>
</div>
);
}
export default H5Coverage;

View File

@@ -0,0 +1,246 @@
import React, { useState, useEffect } from 'react';
interface H5File {
filename: string;
nodeId: string;
date: string;
}
interface WaveformData {
samples: number[];
sample_rate: number;
duration_sec: number;
channel: number;
channel_name: string;
unit: string;
stats: {
min: number;
max: number;
mean: number;
std: number;
rms: number;
};
}
export default function H5Dashboard() {
const [files, setFiles] = useState<H5File[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [waveformData, setWaveformData] = useState<WaveformData | null>(null);
const [channel, setChannel] = useState(1);
const [loading, setLoading] = useState(false);
const [startTime, setStartTime] = useState(0);
const [duration, setDuration] = useState(10);
const [globalView, setGlobalView] = useState(false);
useEffect(() => {
fetch('/seismic/api/h5/files')
.then(r => r.json())
.then(data => setFiles(data.files || []))
.catch(console.error);
}, []);
const loadWaveform = async (file: string, ch: number, start: number, dur: number, global: boolean = false) => {
setLoading(true);
try {
const params = new URLSearchParams({
file,
channel: String(ch),
start: String(start),
duration: global ? '0' : String(dur)
});
const response = await fetch(`/seismic/api/h5/data?${params}`);
const data = await response.json();
setWaveformData(data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const handleFileSelect = (file: string) => {
setSelectedFile(file);
setGlobalView(false);
loadWaveform(file, channel, startTime, duration, false);
};
const handleChannelChange = (ch: number) => {
setChannel(ch);
if (selectedFile) loadWaveform(selectedFile, ch, startTime, duration, globalView);
};
const handleGlobalView = () => {
if (selectedFile) {
setGlobalView(true);
loadWaveform(selectedFile, channel, 0, 0, true);
}
};
return (
<div style={{ padding: '20px', maxWidth: '1400px', margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: '30px' }}>
<h1 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#1e293b', marginBottom: '10px' }}>
🎯 Dashboard H5 - Visualisation Waveforms
</h1>
<p style={{ color: '#64748b', fontSize: '1.1rem' }}>
{files.length} fichiers H5 calibrés disponibles
</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: '20px' }}>
{/* Sidebar - Liste des fichiers */}
<div style={{ background: 'white', padding: '20px', borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)', maxHeight: '80vh', overflowY: 'auto' }}>
<h2 style={{ fontSize: '1.2rem', fontWeight: 'bold', color: '#1e293b', marginBottom: '15px' }}>
📁 Fichiers H5
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{files.map((file, idx) => (
<button
key={idx}
onClick={() => handleFileSelect(file.filename)}
style={{
textAlign: 'left',
padding: '12px',
background: selectedFile === file.filename ? '#3b82f6' : '#f8fafc',
color: selectedFile === file.filename ? 'white' : '#334155',
border: selectedFile === file.filename ? '2px solid #2563eb' : '1px solid #e2e8f0',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '0.85rem',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
if (selectedFile !== file.filename) {
e.currentTarget.style.background = '#f1f5f9';
}
}}
onMouseLeave={(e) => {
if (selectedFile !== file.filename) {
e.currentTarget.style.background = '#f8fafc';
}
}}
>
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>Node {file.nodeId}</div>
<div style={{ fontSize: '0.75rem', opacity: 0.8 }}>{file.date}</div>
</button>
))}
</div>
</div>
{/* Main content - Waveform */}
<div style={{ background: 'white', padding: '20px', borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
{!selectedFile && (
<div style={{ textAlign: 'center', padding: '60px', color: '#64748b' }}>
<div style={{ fontSize: '3rem', marginBottom: '20px' }}>📊</div>
<p style={{ fontSize: '1.2rem' }}>Sélectionnez un fichier H5 pour visualiser les waveforms</p>
</div>
)}
{selectedFile && (
<>
{/* Controls */}
<div style={{ marginBottom: '20px', display: 'flex', gap: '15px', flexWrap: 'wrap', alignItems: 'center' }}>
<div>
<label style={{ fontSize: '0.9rem', fontWeight: 'bold', color: '#475569', display: 'block', marginBottom: '5px' }}>
Canal :
</label>
<div style={{ display: 'flex', gap: '5px' }}>
{[1, 2, 3, 4].map(ch => (
<button
key={ch}
onClick={() => handleChannelChange(ch)}
style={{
padding: '8px 12px',
background: channel === ch ? '#3b82f6' : '#f1f5f9',
color: channel === ch ? 'white' : '#334155',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: 'bold'
}}
>
{ch <= 3 ? `Geo ${ch}` : 'Hydro'}
</button>
))}
</div>
</div>
<button
onClick={handleGlobalView}
disabled={loading}
style={{
padding: '8px 16px',
background: globalView ? '#10b981' : '#8b5cf6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: loading ? 'wait' : 'pointer',
fontWeight: 'bold'
}}
>
🌍 Vue Globale
</button>
</div>
{/* Waveform Display */}
{loading && (
<div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>
<div style={{ fontSize: '2rem', marginBottom: '10px' }}></div>
<p>Chargement des données...</p>
</div>
)}
{waveformData && !loading && (
<div>
{/* Info bar */}
<div style={{ background: '#f8fafc', padding: '15px', borderRadius: '8px', marginBottom: '20px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '10px' }}>
<div>
<div style={{ fontSize: '0.75rem', color: '#64748b', marginBottom: '3px' }}>Canal</div>
<div style={{ fontWeight: 'bold', color: '#1e293b' }}>{waveformData.channel_name}</div>
</div>
<div>
<div style={{ fontSize: '0.75rem', color: '#64748b', marginBottom: '3px' }}>Unité</div>
<div style={{ fontWeight: 'bold', color: '#1e293b' }}>{waveformData.unit}</div>
</div>
<div>
<div style={{ fontSize: '0.75rem', color: '#64748b', marginBottom: '3px' }}>Durée</div>
<div style={{ fontWeight: 'bold', color: '#1e293b' }}>{waveformData.duration_sec.toFixed(1)} s</div>
</div>
<div>
<div style={{ fontSize: '0.75rem', color: '#64748b', marginBottom: '3px' }}>Échantillons</div>
<div style={{ fontWeight: 'bold', color: '#1e293b' }}>{waveformData.samples.length}</div>
</div>
</div>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))', gap: '10px', marginBottom: '20px' }}>
{Object.entries(waveformData.stats).map(([key, value]) => (
<div key={key} style={{ background: '#f0f9ff', padding: '10px', borderRadius: '6px', textAlign: 'center' }}>
<div style={{ fontSize: '0.75rem', color: '#0369a1', marginBottom: '3px', textTransform: 'uppercase' }}>{key}</div>
<div style={{ fontWeight: 'bold', color: '#0c4a6e', fontSize: '0.9rem' }}>{value.toExponential(3)}</div>
</div>
))}
</div>
{/* Waveform visualization (simple text representation for now) */}
<div style={{ background: '#0f172a', padding: '20px', borderRadius: '8px', color: '#94a3b8', fontFamily: 'monospace', fontSize: '0.75rem', overflowX: 'auto' }}>
<div style={{ marginBottom: '10px', color: '#f1f5f9', fontWeight: 'bold' }}>
Waveform Preview (premiers échantillons) :
</div>
<pre style={{ margin: 0 }}>
{waveformData.samples.slice(0, 50).map((v, i) =>
`[${i.toString().padStart(4, '0')}] ${v.toExponential(4)} ${waveformData.unit}`
).join('\n')}
{waveformData.samples.length > 50 && `\n... (${waveformData.samples.length - 50} échantillons restants)`}
</pre>
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}