Fix coverage: add /api/coverage route, remove stray gather code from loadCoverage
This commit is contained in:
276
frontend_src/App.tsx
Normal file
276
frontend_src/App.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { MapContainer, TileLayer } from 'react-leaflet';
|
||||
import NodeMarkers from './components/NodeMarkers';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import SeismicSection from './SeismicSection';
|
||||
import H5Coverage from './components/H5Coverage';
|
||||
import DataDocumentation from './components/DataDocumentation';
|
||||
import H5Dashboard from './components/H5Dashboard';
|
||||
import CampaignDocs from "./components/CampaignDocs";
|
||||
import { Node, DataWindow } from './types';
|
||||
|
||||
const API_BASE = '/seismic/api';
|
||||
const DEFAULT_CENTER: [number, number] = [43.40, 3.70];
|
||||
|
||||
function App() {
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [dates, setDates] = useState<string[]>([]);
|
||||
const [selectedDate, setSelectedDate] = useState<string>('');
|
||||
const [selectedChannel, setSelectedChannel] = useState<string>('ch0');
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||
const [dataWindow, setDataWindow] = useState<DataWindow | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sampleRate, setSampleRate] = useState(200);
|
||||
const [showOnlyWithData, setShowOnlyWithData] = useState(false);
|
||||
const [rmsTimeline, setRmsTimeline] = useState<any>(null);
|
||||
const [allAdcValues, setAllAdcValues] = useState<Record<string, number>>({});
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [playSpeed, setPlaySpeed] = useState(1);
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [chatMsg, setChatMsg] = useState('');
|
||||
const [chatHistory, setChatHistory] = useState<any[]>([]);
|
||||
const [showSection, setShowSection] = useState(false);
|
||||
const [showDashboard, setShowDashboard] = useState(false);
|
||||
const [showCampaignDocs, setShowCampaignDocs] = useState(false);
|
||||
const [showDocumentation, setShowDocumentation] = useState(false);
|
||||
const [showCoverage, setShowCoverage] = useState(false);
|
||||
const [migrationStatus, setMigrationStatus] = useState<any>(null);
|
||||
|
||||
// 🔗 Deep-linking: Lire les paramètres URL au démarrage
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const urlDate = params.get('date');
|
||||
const urlChannel = params.get('channel');
|
||||
const urlTime = params.get('time');
|
||||
const urlNode = params.get('node');
|
||||
|
||||
if (urlDate) setSelectedDate(urlDate);
|
||||
if (urlChannel) setSelectedChannel(urlChannel);
|
||||
if (urlTime) setCurrentTime(parseFloat(urlTime));
|
||||
if (urlNode && nodes.length > 0) {
|
||||
const node = nodes.find(n => n.id === urlNode);
|
||||
if (node) setSelectedNode(node);
|
||||
}
|
||||
}, [nodes]); // Se déclenche quand les nodes sont chargés
|
||||
|
||||
// 🔗 Deep-linking: Synchroniser l'URL quand les états changent
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (selectedDate) params.set('date', selectedDate);
|
||||
if (selectedChannel) params.set('channel', selectedChannel);
|
||||
if (currentTime > 1000000) params.set('time', currentTime.toString());
|
||||
if (selectedNode) params.set('node', selectedNode.id);
|
||||
|
||||
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
||||
window.history.pushState({}, '', newUrl);
|
||||
}, [selectedDate, selectedChannel, currentTime, selectedNode]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/nodes`).then(r => r.json()).then(data => {
|
||||
setNodes(data.nodes || []);
|
||||
setSampleRate(data.sampleRateHz || 200);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/dates`).then(r => r.json()).then(data => {
|
||||
const dateList = data.dates || [];
|
||||
setDates(dateList);
|
||||
if (dateList.length > 0 && !selectedDate) setSelectedDate(dateList[dateList.length - 1]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Polling de la migration
|
||||
useEffect(() => {
|
||||
const checkStatus = () => {
|
||||
fetch(`${API_BASE}/migration-status`)
|
||||
.then(r => r.json())
|
||||
.then(data => setMigrationStatus(data))
|
||||
.catch(() => {});
|
||||
};
|
||||
const interval = setInterval(checkStatus, 5000);
|
||||
checkStatus();
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDate || !selectedChannel) return;
|
||||
setRmsTimeline(null);
|
||||
fetch(`${API_BASE}/rms-timeline?date=${selectedDate}&channel=${selectedChannel}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data && data.nodes && Object.keys(data.nodes).length > 0) {
|
||||
setRmsTimeline(data);
|
||||
const allPts = Object.values(data.nodes).flat() as any[];
|
||||
if (allPts.length > 0) {
|
||||
const minTs = Math.min(...allPts.map(p => p.ts));
|
||||
if (minTs > 1000000) setCurrentTime(minTs);
|
||||
}
|
||||
} else {
|
||||
const ts = new Date(selectedDate).getTime() / 1000;
|
||||
if (!isNaN(ts)) setCurrentTime(ts);
|
||||
}
|
||||
});
|
||||
}, [selectedDate, selectedChannel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(prev => prev + (60 * (playSpeed / 5)));
|
||||
}, 200);
|
||||
return () => clearInterval(interval);
|
||||
}, [isPlaying, playSpeed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rmsTimeline || !rmsTimeline.nodes || !currentTime) {
|
||||
setAllAdcValues({});
|
||||
return;
|
||||
}
|
||||
const values: Record<string, number> = {};
|
||||
Object.entries(rmsTimeline.nodes).forEach(([nodeId, dataPoints]) => {
|
||||
const pts = dataPoints as any[];
|
||||
const point = pts.find(p => p.ts >= currentTime) || pts[pts.length - 1];
|
||||
if (point) values[nodeId] = point.rms;
|
||||
});
|
||||
setAllAdcValues(values);
|
||||
}, [currentTime, rmsTimeline]);
|
||||
|
||||
const loadNodeData = useCallback(async () => {
|
||||
if (!selectedNode || !selectedDate || currentTime < 1000000) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/data?node=${selectedNode.id}&date=${selectedDate}&channel=${selectedChannel}&start=${Math.floor(currentTime)}`);
|
||||
const data = await res.json();
|
||||
if (data && data.samples) setDataWindow({ ...data, node: selectedNode.id, date: selectedDate, channel: selectedChannel });
|
||||
else setDataWindow(null);
|
||||
} catch (e) { setDataWindow(null); }
|
||||
setLoading(false);
|
||||
}, [selectedNode, selectedDate, selectedChannel, currentTime]);
|
||||
|
||||
useEffect(() => { loadNodeData(); }, [selectedNode, selectedDate, selectedChannel]);
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
if (ts < 1000000) return '--:--:--';
|
||||
return new Date(ts * 1000).toISOString().substr(11, 8);
|
||||
};
|
||||
|
||||
const sendChat = async () => {
|
||||
if (!chatMsg) return;
|
||||
const newHist = [...chatHistory, { role: 'user', text: chatMsg }];
|
||||
setChatHistory(newHist);
|
||||
setChatMsg('');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/chat`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: chatMsg })
|
||||
});
|
||||
const data = await res.json();
|
||||
setChatHistory([...newHist, { role: 'agent', text: data.response }]);
|
||||
} catch (e) { setChatHistory([...newHist, { role: 'agent', text: "Erreur agent." }]); }
|
||||
};
|
||||
|
||||
return (<>
|
||||
<div className="app-container" style={{ display: 'flex', flexDirection: 'column', height: '100vh', width: '100vw', background: '#0f172a', color: '#fff' }}>
|
||||
<header style={{ height: '60px', display: 'flex', alignItems: 'center', padding: '0 20px', background: '#1e293b', borderBottom: '1px solid #334155' }}>
|
||||
<h1 style={{ margin: 0, fontSize: '1.2rem' }}>Seismic Viewer</h1>
|
||||
|
||||
{migrationStatus && migrationStatus.processed_files < migrationStatus.total_files && (
|
||||
<div style={{ marginLeft: '20px', background: '#334155', padding: '5px 10px', borderRadius: '15px', fontSize: '0.75rem', border: '1px solid #4ade80' }}>
|
||||
<span style={{ color: '#4ade80', fontWeight: 'bold' }}>⚡ Migration : </span>
|
||||
{Math.round((migrationStatus.processed_files / migrationStatus.total_files) * 100)}%
|
||||
({migrationStatus.processed_files}/{migrationStatus.total_files})
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ color: '#94a3b8', fontSize: '0.8rem', marginLeft: '20px' }}>
|
||||
Nodes: {nodes.length} | Actifs: {nodes.filter(n=>n.hasDates).length}
|
||||
<button onClick={() => setShowDashboard(true)} style={{ background: '#f59e0b', color: '#fff', border: 'none', padding: '5px 15px', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold' }}>🎯 Dashboard H5</button>
|
||||
<button onClick={() => setShowCampaignDocs(true)} style={{ background: "#8b5cf6", border: "none", color: "#fff", padding: "8px 16px", borderRadius: "6px", cursor: "pointer", fontWeight: "bold", fontSize: "0.9rem" }}>📚 Documentation Campagne</button>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '10px' }}>
|
||||
<button onClick={() => setShowSection(true)} style={{ background: '#8b5cf6', color: '#fff', border: 'none', padding: '5px 15px', borderRadius: '4px', cursor: 'pointer' }}>Vue Globale</button>
|
||||
<button onClick={() => setShowDocumentation(true)} style={{ background: '#10b981', color: '#fff', border: 'none', padding: '5px 15px', borderRadius: '4px', cursor: 'pointer' }}>📖 Documentation</button>
|
||||
<button onClick={() => setShowCoverage(true)} style={{ background: '#3b82f6', color: '#fff', border: 'none', padding: '5px 15px', borderRadius: '4px', cursor: 'pointer' }}>Coverage H5</button>
|
||||
<select value={selectedDate} onChange={e => setSelectedDate(e.target.value)} style={{ background: '#0f172a', color: '#fff', border: '1px solid #334155' }}>
|
||||
{dates.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
<select value={selectedChannel} onChange={e => setSelectedChannel(e.target.value)} style={{ background: '#0f172a', color: '#fff', border: '1px solid #334155' }}>
|
||||
{['ch0', 'ch1', 'ch2', 'ch3'].map(ch => <option key={ch} value={ch}>{ch.toUpperCase()}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ height: '50px', background: '#0f172a', display: 'flex', alignItems: 'center', padding: '0 20px', gap: '15px', borderBottom: '1px solid #334155' }}>
|
||||
<button onClick={() => setIsPlaying(!isPlaying)} aria-label={isPlaying ? 'Mettre en pause' : 'Lancer la lecture'} style={{ background: isPlaying ? '#ef4444' : '#22c55e', color: '#fff', border: 'none', padding: '5px 15px', borderRadius: '4px', fontWeight: 'bold', cursor: 'pointer' }}>
|
||||
{isPlaying ? 'PAUSE' : 'PLAY'}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '2px', background: '#1e293b', padding: '2px', borderRadius: '4px' }}>
|
||||
{[1, 10, 100, 1000].map(s => (
|
||||
<button key={s} onClick={() => setPlaySpeed(s)} aria-label={`Vitesse ${s}x`} style={{ background: playSpeed === s ? '#3b82f6' : 'transparent', color: '#fff', border: 'none', padding: '2px 8px', fontSize: '0.7rem', cursor: 'pointer' }}>x{s}</button>
|
||||
))}
|
||||
</div>
|
||||
<span style={{ fontFamily: 'monospace', color: '#4ade80', fontSize: '1.1rem', minWidth: '80px' }}>{formatTime(currentTime)}</span>
|
||||
<input type="range" aria-label="Curseur temporel" style={{ flex: 1 }} min={currentTime - 1800} max={currentTime + 1800} value={currentTime} onChange={e => { setCurrentTime(Number(e.target.value)); setIsPlaying(false); }} />
|
||||
</div>
|
||||
|
||||
<main style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<MapContainer center={DEFAULT_CENTER} zoom={13} style={{ height: '100%', width: '100%' }}>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<NodeMarkers nodes={nodes} selectedNode={selectedNode} onSelectNode={setSelectedNode} adcValues={allAdcValues} showOnlyWithData={showOnlyWithData} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
<Sidebar selectedNode={selectedNode} dataWindow={dataWindow} loading={loading} sampleRate={sampleRate} />
|
||||
</main>
|
||||
|
||||
<SeismicSection nodes={nodes} currentTime={currentTime} channel={selectedChannel} visible={showSection} onClose={() => setShowSection(false)} />
|
||||
|
||||
<div style={{ position: 'fixed', bottom: '20px', right: '20px', zIndex: 10000 }}>
|
||||
{!chatOpen ? (
|
||||
<button onClick={() => setChatOpen(true)} aria-label="Ouvrir le chat assistant" style={{ width: '50px', height: '50px', borderRadius: '25px', background: '#e94560', border: 'none', color: '#fff', fontSize: '20px', cursor: 'pointer', boxShadow: '0 4px 12px rgba(0,0,0,0.3)' }}>💬</button>
|
||||
) : (
|
||||
<div style={{ background: '#1e293b', width: '300px', height: '400px', borderRadius: '8px', display: 'flex', flexDirection: 'column', border: '1px solid #334155', boxShadow: '0 8px 24px rgba(0,0,0,0.4)' }}>
|
||||
<div style={{ padding: '10px', background: '#334155', display: 'flex', justifyContent: 'space-between', borderRadius: '8px 8px 0 0' }}>
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 'bold' }}>Assistant</span>
|
||||
<button onClick={() => setChatOpen(false)} aria-label="Fermer le chat" style={{ background: 'none', border: 'none', color: '#fff', cursor: 'pointer' }}>×</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{chatHistory.map((m, i) => (
|
||||
<div key={i} style={{ alignSelf: m.role === 'user' ? 'flex-end' : 'flex-start', background: m.role === 'user' ? '#3b82f6' : '#475569', padding: '6px 10px', borderRadius: '8px', fontSize: '0.8rem', maxWidth: '85%' }}>{m.text}</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ padding: '10px', borderTop: '1px solid #334155', display: 'flex', gap: '5px' }}>
|
||||
<input value={chatMsg} onChange={e => setChatMsg(e.target.value)} onKeyPress={e => e.key === 'Enter' && sendChat()} aria-label="Poser une question" style={{ flex: 1, background: '#0f172a', border: '1px solid #334155', color: '#fff', padding: '5px', borderRadius: '4px', fontSize: '0.8rem' }} placeholder="Question…" />
|
||||
<button onClick={sendChat} style={{ background: '#e94560', border: 'none', color: '#fff', padding: '5px 10px', borderRadius: '4px', fontSize: '0.8rem' }}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showDashboard && (
|
||||
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "#f1f5f9", zIndex: 9998, overflow: "auto" }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setShowDashboard(false)} style={{ position: "fixed", top: "10px", right: "10px", background: "#ef4444", color: "white", border: "none", padding: "10px 20px", borderRadius: "4px", cursor: "pointer", fontWeight: "bold", zIndex: 9999 }}>✕ Fermer Dashboard</button>
|
||||
<H5Dashboard />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showCampaignDocs && (
|
||||
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "#f1f5f9", zIndex: 9998, overflow: "auto" }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setShowCampaignDocs(false)} style={{ position: "fixed", top: "10px", right: "10px", background: "#ef4444", color: "white", border: "none", padding: "10px 20px", borderRadius: "4px", cursor: "pointer", fontWeight: "bold", zIndex: 9999 }}>✕ Fermer</button>
|
||||
<CampaignDocs />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showDocumentation && <DataDocumentation onClose={() => setShowDocumentation(false)} />}
|
||||
{showCoverage && <H5Coverage onClose={() => setShowCoverage(false)} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
346
frontend_src/App.tsx.backup
Normal file
346
frontend_src/App.tsx.backup
Normal file
@@ -0,0 +1,346 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { MapContainer, TileLayer } from 'react-leaflet';
|
||||
import NodeMarkers from './components/NodeMarkers';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import SeismicSection from './SeismicSection';
|
||||
import H5Coverage from './components/H5Coverage';
|
||||
import DataDocumentation from './components/DataDocumentation';
|
||||
import H5Dashboard from './components/H5Dashboard';
|
||||
import CampaignDocs from "./components/CampaignDocs";
|
||||
import { Node, DataWindow } from './types';
|
||||
|
||||
const API_BASE = '/seismic/api';
|
||||
const DEFAULT_CENTER: [number, number] = [43.40, 3.70];
|
||||
|
||||
function App() {
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [dates, setDates] = useState<string[]>([]);
|
||||
const [selectedDate, setSelectedDate] = useState<string>('');
|
||||
const [selectedChannel, setSelectedChannel] = useState<string>('ch0');
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||
const [dataWindow, setDataWindow] = useState<DataWindow | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sampleRate, setSampleRate] = useState(200);
|
||||
const [showOnlyWithData, setShowOnlyWithData] = useState(false);
|
||||
const [rmsTimeline, setRmsTimeline] = useState<any>(null);
|
||||
const [allAdcValues, setAllAdcValues] = useState<Record<string, number>>({});
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [playSpeed, setPlaySpeed] = useState(1);
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [chatMsg, setChatMsg] = useState('');
|
||||
const [chatHistory, setChatHistory] = useState<any[]>([]);
|
||||
const [showSection, setShowSection] = useState(false);
|
||||
const [showDashboard, setShowDashboard] = useState(false);
|
||||
const [showCampaignDocs, setShowCampaignDocs] = useState(false);
|
||||
const [showDocumentation, setShowDocumentation] = useState(false);
|
||||
const [showCoverage, setShowCoverage] = useState(false);
|
||||
const [migrationStatus, setMigrationStatus] = useState<any>(null);
|
||||
|
||||
// 🔗 Deep-linking: Lire les paramètres URL au démarrage
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const urlDate = params.get('date');
|
||||
const urlChannel = params.get('channel');
|
||||
const urlTime = params.get('time');
|
||||
|
||||
if (urlDate) setSelectedDate(urlDate);
|
||||
if (urlChannel) setSelectedChannel(urlChannel);
|
||||
if (urlTime) setCurrentTime(parseFloat(urlTime));
|
||||
if (urlNode && nodes.length > 0) {
|
||||
const node = nodes.find(n => n.id === urlNode);
|
||||
if (node) setSelectedNode(node);
|
||||
}
|
||||
}, [nodes]); // Se déclenche quand les nodes sont chargés
|
||||
|
||||
// 🔗 Deep-linking: Synchroniser l'URL quand les états changent
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (selectedDate) params.set('date', selectedDate);
|
||||
if (selectedChannel) params.set('channel', selectedChannel);
|
||||
if (currentTime > 1000000) params.set('time', currentTime.toString());
|
||||
if (selectedNode) params.set('node', selectedNode.id);
|
||||
|
||||
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
||||
window.history.pushState({}, '', newUrl);
|
||||
}, [selectedDate, selectedChannel, currentTime, selectedNode]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/nodes`).then(r => r.json()).then(data => {
|
||||
setNodes(data.nodes || []);
|
||||
setSampleRate(data.sampleRateHz || 200);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/dates`).then(r => r.json()).then(data => {
|
||||
const dateList = data.dates || [];
|
||||
setDates(dateList);
|
||||
if (dateList.length > 0 && !selectedDate) setSelectedDate(dateList[dateList.length - 1]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Polling de la migration
|
||||
useEffect(() => {
|
||||
const checkStatus = () => {
|
||||
fetch(`${API_BASE}/migration-status`)
|
||||
.then(r => r.json())
|
||||
.then(data => setMigrationStatus(data))
|
||||
.catch(() => {});
|
||||
};
|
||||
const interval = setInterval(checkStatus, 5000);
|
||||
checkStatus();
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDate || !selectedChannel) return;
|
||||
setRmsTimeline(null);
|
||||
fetch(`${API_BASE}/rms-timeline?date=${selectedDate}&channel=${selectedChannel}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data && data.nodes && Object.keys(data.nodes).length > 0) {
|
||||
setRmsTimeline(data);
|
||||
const allPts = Object.values(data.nodes).flat() as any[];
|
||||
if (allPts.length > 0) {
|
||||
const minTs = Math.min(...allPts.map(p => p.ts));
|
||||
if (minTs > 1000000) setCurrentTime(minTs);
|
||||
}
|
||||
} else {
|
||||
const ts = new Date(selectedDate).getTime() / 1000;
|
||||
if (!isNaN(ts)) setCurrentTime(ts);
|
||||
}
|
||||
});
|
||||
}, [selectedDate, selectedChannel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(prev => prev + (60 * (playSpeed / 5)));
|
||||
}, 200);
|
||||
return () => clearInterval(interval);
|
||||
}, [isPlaying, playSpeed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rmsTimeline || !rmsTimeline.nodes || !currentTime) {
|
||||
setAllAdcValues({});
|
||||
return;
|
||||
}
|
||||
const values: Record<string, number> = {};
|
||||
Object.entries(rmsTimeline.nodes).forEach(([nodeId, dataPoints]) => {
|
||||
const pts = dataPoints as any[];
|
||||
const point = pts.find(p => p.ts >= currentTime) || pts[pts.length - 1];
|
||||
if (point) values[nodeId] = point.rms;
|
||||
});
|
||||
setAllAdcValues(values);
|
||||
}, [currentTime, rmsTimeline]);
|
||||
|
||||
const loadNodeData = useCallback(async () => {
|
||||
if (!selectedNode || !selectedDate || currentTime < 1000000) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/data?node=${selectedNode.id}&date=${selectedDate}&channel=${selectedChannel}&start=${Math.floor(currentTime)}`);
|
||||
const data = await res.json();
|
||||
if (data && data.samples) setDataWindow({ ...data, node: selectedNode.id, date: selectedDate, channel: selectedChannel });
|
||||
else setDataWindow(null);
|
||||
} catch (e) { setDataWindow(null); }
|
||||
setLoading(false);
|
||||
}, [selectedNode, selectedDate, selectedChannel, currentTime]);
|
||||
|
||||
useEffect(() => { loadNodeData(); }, [selectedNode, selectedDate, selectedChannel]);
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
if (ts < 1000000) return '--:--:--';
|
||||
return new Date(ts * 1000).toISOString().substr(11, 8);
|
||||
};
|
||||
|
||||
const sendChat = async () => {
|
||||
if (!chatMsg) return;
|
||||
const newHist = [...chatHistory, { role: 'user', text: chatMsg }];
|
||||
setChatHistory(newHist);
|
||||
setChatMsg('');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/chat`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: chatMsg })
|
||||
});
|
||||
const data = await res.json();
|
||||
setChatHistory([...newHist, { role: 'agent', text: data.response }]);
|
||||
} catch (e) { setChatHistory([...newHist, { role: 'agent', text: "Erreur agent." }]); }
|
||||
};
|
||||
|
||||
return (<>
|
||||
<div className="app-container" style={{ display: 'flex', flexDirection: 'column', height: '100vh', width: '100vw', background: '#0f172a', color: '#fff' }}>
|
||||
<header style={{ height: '60px', display: 'flex', alignItems: 'center', padding: '0 20px', background: '#1e293b', borderBottom: '1px solid #334155' }}>
|
||||
<h1 style={{ margin: 0, fontSize: '1.2rem' }}>Seismic Viewer</h1>
|
||||
|
||||
{migrationStatus && migrationStatus.processed_files < migrationStatus.total_files && (
|
||||
<div style={{ marginLeft: '20px', background: '#334155', padding: '5px 10px', borderRadius: '15px', fontSize: '0.75rem', border: '1px solid #4ade80' }}>
|
||||
<span style={{ color: '#4ade80', fontWeight: 'bold' }}>⚡ Migration : </span>
|
||||
{Math.round((migrationStatus.processed_files / migrationStatus.total_files) * 100)}%
|
||||
({migrationStatus.processed_files}/{migrationStatus.total_files})
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ color: '#94a3b8', fontSize: '0.8rem', marginLeft: '20px' }}>
|
||||
Nodes: {nodes.length} | Actifs: {nodes.filter(n=>n.hasDates).length}
|
||||
<button onClick={() => setShowDashboard(true)} style={{ background: '#f59e0b', color: '#fff', border: 'none', padding: '5px 15px', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold' }}>🎯 Dashboard H5</button>
|
||||
<button onClick={() => setShowCampaignDocs(true)} style={{ background: "#8b5cf6", border: "none", color: "#fff", padding: "8px 16px", borderRadius: "6px", cursor: "pointer", fontWeight: "bold", fontSize: "0.9rem" }}>📚 Documentation Campagne</button>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '10px' }}>
|
||||
<button onClick={() => setShowSection(true)} style={{ background: '#8b5cf6', color: '#fff', border: 'none', padding: '5px 15px', borderRadius: '4px', cursor: 'pointer' }}>Vue Globale</button>
|
||||
<button onClick={() => setShowDocumentation(true)} style={{ background: '#10b981', color: '#fff', border: 'none', padding: '5px 15px', borderRadius: '4px', cursor: 'pointer' }}>📖 Documentation</button>
|
||||
<button onClick={() => setShowCoverage(true)} style={{ background: '#3b82f6', color: '#fff', border: 'none', padding: '5px 15px', borderRadius: '4px', cursor: 'pointer' }}>Coverage H5</button>
|
||||
<select value={selectedDate} onChange={e => setSelectedDate(e.target.value)} style={{ background: '#0f172a', color: '#fff', border: '1px solid #334155' }}>
|
||||
{dates.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
<select value={selectedChannel} onChange={e => setSelectedChannel(e.target.value)} style={{ background: '#0f172a', color: '#fff', border: '1px solid #334155' }}>
|
||||
{['ch0', 'ch1', 'ch2', 'ch3'].map(ch => <option key={ch} value={ch}>{ch.toUpperCase()}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ height: '50px', background: '#0f172a', display: 'flex', alignItems: 'center', padding: '0 20px', gap: '15px', borderBottom: '1px solid #334155' }}>
|
||||
<button onClick={() => setIsPlaying(!isPlaying)} aria-label={isPlaying ? 'Mettre en pause' : 'Lancer la lecture'} style={{ background: isPlaying ? '#ef4444' : '#22c55e', color: '#fff', border: 'none', padding: '5px 15px', borderRadius: '4px', fontWeight: 'bold', cursor: 'pointer' }}>
|
||||
{isPlaying ? 'PAUSE' : 'PLAY'}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '2px', background: '#1e293b', padding: '2px', borderRadius: '4px' }}>
|
||||
{[1, 10, 100, 1000].map(s => (
|
||||
<button key={s} onClick={() => setPlaySpeed(s)} aria-label={`Vitesse ${s}x`} style={{ background: playSpeed === s ? '#3b82f6' : 'transparent', color: '#fff', border: 'none', padding: '2px 8px', fontSize: '0.7rem', cursor: 'pointer' }}>x{s}</button>
|
||||
))}
|
||||
</div>
|
||||
<span style={{ fontFamily: 'monospace', color: '#4ade80', fontSize: '1.1rem', minWidth: '80px' }}>{formatTime(currentTime)}</span>
|
||||
<input type="range" aria-label="Curseur temporel" style={{ flex: 1 }} min={currentTime - 1800} max={currentTime + 1800} value={currentTime} onChange={e => { setCurrentTime(Number(e.target.value)); setIsPlaying(false); }} />
|
||||
</div>
|
||||
|
||||
<main style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<MapContainer center={DEFAULT_CENTER} zoom={13} style={{ height: '100%', width: '100%' }}>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<NodeMarkers nodes={nodes} selectedNode={selectedNode} onSelectNode={setSelectedNode} adcValues={allAdcValues} showOnlyWithData={showOnlyWithData} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
<Sidebar selectedNode={selectedNode} dataWindow={dataWindow} loading={loading} sampleRate={sampleRate} />
|
||||
</main>
|
||||
|
||||
<SeismicSection nodes={nodes} currentTime={currentTime} channel={selectedChannel} visible={showSection} onClose={() => setShowSection(false)} />
|
||||
|
||||
<div style={{ position: 'fixed', bottom: '20px', right: '20px', zIndex: 10000 }}>
|
||||
{!chatOpen ? (
|
||||
<button onClick={() => setChatOpen(true)} aria-label="Ouvrir le chat assistant" style={{ width: '50px', height: '50px', borderRadius: '25px', background: '#e94560', border: 'none', color: '#fff', fontSize: '20px', cursor: 'pointer', boxShadow: '0 4px 12px rgba(0,0,0,0.3)' }}>💬</button>
|
||||
) : (
|
||||
<div style={{ background: '#1e293b', width: '300px', height: '400px', borderRadius: '8px', display: 'flex', flexDirection: 'column', border: '1px solid #334155', boxShadow: '0 8px 24px rgba(0,0,0,0.4)' }}>
|
||||
<div style={{ padding: '10px', background: '#334155', display: 'flex', justifyContent: 'space-between', borderRadius: '8px 8px 0 0' }}>
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 'bold' }}>Assistant</span>
|
||||
<button onClick={() => setChatOpen(false)} aria-label="Fermer le chat" style={{ background: 'none', border: 'none', color: '#fff', cursor: 'pointer' }}>×</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{chatHistory.map((m, i) => (
|
||||
<div key={i} style={{ alignSelf: m.role === 'user' ? 'flex-end' : 'flex-start', background: m.role === 'user' ? '#3b82f6' : '#475569', padding: '6px 10px', borderRadius: '8px', fontSize: '0.8rem', maxWidth: '85%' }}>{m.text}</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ padding: '10px', borderTop: '1px solid #334155', display: 'flex', gap: '5px' }}>
|
||||
<input value={chatMsg} onChange={e => setChatMsg(e.target.value)} onKeyPress={e => e.key === 'Enter' && sendChat()} aria-label="Poser une question" style={{ flex: 1, background: '#0f172a', border: '1px solid #334155', color: '#fff', padding: '5px', borderRadius: '4px', fontSize: '0.8rem' }} placeholder="Question…" />
|
||||
<button onClick={sendChat} style={{ background: '#e94560', border: 'none', color: '#fff', padding: '5px 10px', borderRadius: '4px', fontSize: '0.8rem' }}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showDashboard && (
|
||||
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "#f1f5f9", zIndex: 9998, overflow: "auto" }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setShowDashboard(false)} style={{ position: "fixed", top: "10px", right: "10px", background: "#ef4444", color: "white", border: "none", padding: "10px 20px", borderRadius: "4px", cursor: "pointer", fontWeight: "bold", zIndex: 9999 }}>✕ Fermer Dashboard</button>
|
||||
<H5Dashboard />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "#f1f5f9", zIndex: 9998, overflow: "auto" }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
{showCampaignDocs && (
|
||||
<button onClick={() => setShowCampaignDocs(false)} style={{ position: "fixed", top: "10px", right: "10px", background: "#ef4444", color: "white", border: "none", padding: "10px 20px", borderRadius: "4px", cursor: "pointer", fontWeight: "bold", zIndex: 9999 }}>✕ Fermer</button>
|
||||
<CampaignDocs />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: '#f1f5f9', zIndex: 9998, overflow: 'auto' }}>
|
||||
{showCampaignDocs && (
|
||||
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "#f1f5f9", zIndex: 9998, overflow: "auto" }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setShowCampaignDocs(false)} style={{ position: "fixed", top: "10px", right: "10px", background: "#ef4444", color: "white", border: "none", padding: "10px 20px", borderRadius: "4px", cursor: "pointer", fontWeight: "bold", zIndex: 9999 }}>✕ Fermer</button>
|
||||
<CampaignDocs />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative' }}>
|
||||
{showCampaignDocs && (
|
||||
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "#f1f5f9", zIndex: 9998, overflow: "auto" }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setShowCampaignDocs(false)} style={{ position: "fixed", top: "10px", right: "10px", background: "#ef4444", color: "white", border: "none", padding: "10px 20px", borderRadius: "4px", cursor: "pointer", fontWeight: "bold", zIndex: 9999 }}>✕ Fermer</button>
|
||||
<CampaignDocs />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => setShowDashboard(false)} style={{ position: 'fixed', top: '10px', right: '10px', background: '#ef4444', color: 'white', border: 'none', padding: '10px 20px', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold', zIndex: 9999 }}>✕ Fermer Dashboard</button>
|
||||
{showCampaignDocs && (
|
||||
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "#f1f5f9", zIndex: 9998, overflow: "auto" }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setShowCampaignDocs(false)} style={{ position: "fixed", top: "10px", right: "10px", background: "#ef4444", color: "white", border: "none", padding: "10px 20px", borderRadius: "4px", cursor: "pointer", fontWeight: "bold", zIndex: 9999 }}>✕ Fermer</button>
|
||||
<CampaignDocs />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<H5Dashboard />
|
||||
{showCampaignDocs && (
|
||||
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "#f1f5f9", zIndex: 9998, overflow: "auto" }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setShowCampaignDocs(false)} style={{ position: "fixed", top: "10px", right: "10px", background: "#ef4444", color: "white", border: "none", padding: "10px 20px", borderRadius: "4px", cursor: "pointer", fontWeight: "bold", zIndex: 9999 }}>✕ Fermer</button>
|
||||
<CampaignDocs />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showCampaignDocs && (
|
||||
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "#f1f5f9", zIndex: 9998, overflow: "auto" }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setShowCampaignDocs(false)} style={{ position: "fixed", top: "10px", right: "10px", background: "#ef4444", color: "white", border: "none", padding: "10px 20px", borderRadius: "4px", cursor: "pointer", fontWeight: "bold", zIndex: 9999 }}>✕ Fermer</button>
|
||||
<CampaignDocs />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showCampaignDocs && (
|
||||
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "#f1f5f9", zIndex: 9998, overflow: "auto" }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setShowCampaignDocs(false)} style={{ position: "fixed", top: "10px", right: "10px", background: "#ef4444", color: "white", border: "none", padding: "10px 20px", borderRadius: "4px", cursor: "pointer", fontWeight: "bold", zIndex: 9999 }}>✕ Fermer</button>
|
||||
<CampaignDocs />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{showCampaignDocs && (
|
||||
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "#f1f5f9", zIndex: 9998, overflow: "auto" }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setShowCampaignDocs(false)} style={{ position: "fixed", top: "10px", right: "10px", background: "#ef4444", color: "white", border: "none", padding: "10px 20px", borderRadius: "4px", cursor: "pointer", fontWeight: "bold", zIndex: 9999 }}>✕ Fermer</button>
|
||||
<CampaignDocs />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showDocumentation && <DataDocumentation onClose={() => setShowDocumentation(false)} />}
|
||||
{showCoverage && <H5Coverage onClose={() => setShowCoverage(false)} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
118
frontend_src/NodeMarkers.tsx
Normal file
118
frontend_src/NodeMarkers.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { CircleMarker, Popup, useMap } from 'react-leaflet';
|
||||
import proj4 from 'proj4';
|
||||
import { Node, AdcValues } from '../types';
|
||||
|
||||
// Définition de la projection UTM Zone 31N (WGS84)
|
||||
const UTM31N = "+proj=utm +zone=31 +datum=WGS84 +units=m +no_defs";
|
||||
const WGS84 = "EPSG:4326";
|
||||
|
||||
function utmToLatLon(easting: number, northing: number): [number, number] | null {
|
||||
if (!easting || !northing || isNaN(easting) || isNaN(northing)) return null;
|
||||
try {
|
||||
// Proj4 attend [longitude, latitude] pour le résultat
|
||||
const [lon, lat] = proj4(UTM31N, WGS84, [easting, northing]);
|
||||
return [lat, lon];
|
||||
} catch (e) {
|
||||
console.error('UTM Conversion error:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getMarkerColor(value: number | undefined, maxVal = 500): string {
|
||||
if (value === undefined || value <= 0) return '#444444';
|
||||
const normalized = Math.min(1, Math.sqrt(value) / Math.sqrt(maxVal));
|
||||
if (normalized < 0.3) return `rgb(0, ${Math.round(normalized * 3.3 * 255)}, 255)`;
|
||||
if (normalized < 0.6) return `rgb(0, 255, ${Math.round((1 - (normalized - 0.3) * 3.3) * 255)})`;
|
||||
return `rgb(255, ${Math.round((1 - (normalized - 0.6) * 2.5) * 255)}, 0)`;
|
||||
}
|
||||
|
||||
function getMarkerRadius(value: number | undefined, maxVal = 500): number {
|
||||
if (value === undefined || value <= 0) return 6;
|
||||
const normalized = Math.min(1, Math.sqrt(value) / Math.sqrt(maxVal));
|
||||
return 6 + normalized * 24;
|
||||
}
|
||||
|
||||
interface NodeMarkersProps {
|
||||
nodes: Node[];
|
||||
selectedNode: Node | null;
|
||||
onSelectNode: (node: Node) => void;
|
||||
adcValues: AdcValues;
|
||||
showOnlyWithData?: boolean;
|
||||
}
|
||||
|
||||
function NodeMarkers({ nodes, selectedNode, onSelectNode, adcValues, showOnlyWithData = true }: NodeMarkersProps) {
|
||||
const map = useMap();
|
||||
|
||||
const nodesWithLatLon = useMemo(() => {
|
||||
console.log(`NodeMarkers: Processing ${nodes.length} nodes`);
|
||||
const results = nodes
|
||||
.filter(node => node.position && (!showOnlyWithData || node.hasDates))
|
||||
.map(node => {
|
||||
const coords = utmToLatLon(node.position!.easting, node.position!.northing);
|
||||
if (!coords) return null;
|
||||
return { ...node, lat: coords[0], lon: coords[1] };
|
||||
})
|
||||
.filter((n): n is Node & { lat: number; lon: number } => n !== null);
|
||||
|
||||
console.log(`NodeMarkers: ${results.length} nodes converted to LatLon`);
|
||||
if (results.length > 0) {
|
||||
console.log('Sample node:', results[0].id, results[0].lat, results[0].lon);
|
||||
}
|
||||
return results;
|
||||
}, [nodes, showOnlyWithData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (nodesWithLatLon.length === 0) return;
|
||||
try {
|
||||
const lats = nodesWithLatLon.map(n => n.lat);
|
||||
const lons = nodesWithLatLon.map(n => n.lon);
|
||||
const bounds: [[number, number], [number, number]] = [
|
||||
[Math.min(...lats), Math.min(...lons)],
|
||||
[Math.max(...lats), Math.max(...lons)]
|
||||
];
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
} catch (e) {
|
||||
console.error('fitBounds error:', e);
|
||||
}
|
||||
}, [nodesWithLatLon, map]);
|
||||
|
||||
const currentMax = useMemo(() => {
|
||||
const vals = Object.values(adcValues).filter(v => v > 0);
|
||||
return vals.length > 0 ? Math.max(...vals) : 500;
|
||||
}, [adcValues]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CircleMarker center={[43.40, 3.70]} radius={10} pathOptions={{ color: 'yellow', fillColor: 'yellow', fillOpacity: 1 }}>
|
||||
<Popup>DEBUG: Centre de Sète</Popup>
|
||||
</CircleMarker>
|
||||
{nodesWithLatLon.map(node => {
|
||||
const adcValue = adcValues[node.id];
|
||||
const isSelected = selectedNode?.id === node.id;
|
||||
return (
|
||||
<CircleMarker
|
||||
key={node.id}
|
||||
center={[node.lat, node.lon]}
|
||||
radius={getMarkerRadius(adcValue, currentMax)}
|
||||
pathOptions={{
|
||||
fillColor: getMarkerColor(adcValue, currentMax),
|
||||
fillOpacity: 0.85,
|
||||
color: isSelected ? '#ff0000' : '#ffffff',
|
||||
weight: isSelected ? 4 : 1,
|
||||
}}
|
||||
eventHandlers={{ click: () => onSelectNode(node) }}
|
||||
>
|
||||
<Popup>
|
||||
<strong>Node {node.id}</strong><br/>
|
||||
E: {node.position?.easting.toFixed(0)} N: {node.position?.northing.toFixed(0)}<br/>
|
||||
RMS: {adcValue?.toFixed(2) || 'N/A'}
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default NodeMarkers;
|
||||
113
frontend_src/SeismicSection.tsx
Normal file
113
frontend_src/SeismicSection.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import Plot from 'react-plotly.js';
|
||||
|
||||
interface SeismicSectionProps {
|
||||
nodes: any[];
|
||||
currentTime: number;
|
||||
channel: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const API_BASE = '/seismic/api';
|
||||
|
||||
function SeismicSection({ nodes, currentTime, channel, visible, onClose }: SeismicSectionProps) {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [viewMode, setViewMode] = useState<'day' | 'global'>('day');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setLoading(true);
|
||||
|
||||
const url = viewMode === 'global'
|
||||
? `${API_BASE}/global-history?channel=${channel}`
|
||||
: `${API_BASE}/rms-timeline?date=${new Date(currentTime * 1000).toISOString().split('T')[0]}&channel=${channel}`;
|
||||
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
setData(d.nodes);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [visible, viewMode, channel, currentTime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [visible, onClose]);
|
||||
|
||||
const plotData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const traces: any[] = [];
|
||||
const nodeIds = Object.keys(data).sort();
|
||||
|
||||
nodeIds.forEach((id, index) => {
|
||||
const points = data[id];
|
||||
if (!points || points.length === 0) return;
|
||||
|
||||
const maxRms = Math.max(...points.map((p: any) => p.rms)) || 1;
|
||||
|
||||
traces.push({
|
||||
x: points.map((p: any) => new Date(p.ts * 1000)),
|
||||
y: points.map((p: any) => (p.rms / maxRms) * 0.9 + index),
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
name: `Node ${id}`,
|
||||
line: { color: '#4ade80', width: 1 },
|
||||
fill: 'tonexty',
|
||||
showlegend: false
|
||||
});
|
||||
});
|
||||
return traces;
|
||||
}, [data]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', top: '0', left: '0', right: '0', bottom: '0', background: '#0f172a', zIndex: 3000, display: 'flex', flexDirection: 'column', overflowBehavior: 'contain' }}>
|
||||
<div style={{ padding: '15px 20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#1e293b' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, display: 'inline', marginRight: '20px' }}>Vue Sismique Globale</h2>
|
||||
<div style={{ display: 'inline-flex', gap: '5px', background: '#0f172a', padding: '3px', borderRadius: '4px' }}>
|
||||
<button onClick={() => setViewMode('day')} style={{ background: viewMode === 'day' ? '#3b82f6' : 'transparent', color: '#fff', border: 'none', padding: '5px 15px', cursor: 'pointer' }}>Journée</button>
|
||||
<button onClick={() => setViewMode('global')} style={{ background: viewMode === 'global' ? '#3b82f6' : 'transparent', color: '#fff', border: 'none', padding: '5px 15px', cursor: 'pointer' }}>Toute la période (12j)</button>
|
||||
</div>
|
||||
</div>
|
||||
<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: '10px' }}>
|
||||
{loading ? <div style={{ textAlign: 'center', marginTop: '20%' }}>Fusion des données…</div> : (
|
||||
<Plot
|
||||
data={plotData}
|
||||
layout={{
|
||||
autosize: true,
|
||||
paper_bgcolor: 'rgba(0,0,0,0)',
|
||||
plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
margin: { t: 10, b: 50, l: 60, r: 20 },
|
||||
font: { color: '#94a3b8' },
|
||||
xaxis: { gridcolor: '#1e293b', title: 'Temps' },
|
||||
yaxis: {
|
||||
gridcolor: '#1e293b',
|
||||
tickvals: plotData.map((_, i) => i),
|
||||
ticktext: Object.keys(data || {}).sort().map(id => `b${id}`)
|
||||
}
|
||||
}}
|
||||
config={{ responsive: true }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeismicSection;
|
||||
127
frontend_src/Sidebar.tsx
Normal file
127
frontend_src/Sidebar.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import Plot from 'react-plotly.js';
|
||||
import { Node, DataWindow } from '../types';
|
||||
|
||||
interface SidebarProps {
|
||||
selectedNode: Node | null;
|
||||
dataWindow: DataWindow | null;
|
||||
loading: boolean;
|
||||
sampleRate: number;
|
||||
}
|
||||
|
||||
type ViewMode = 'waveform' | 'rms';
|
||||
|
||||
function Sidebar({ selectedNode, dataWindow, loading, sampleRate }: SidebarProps) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('waveform');
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
|
||||
const samples = dataWindow?.samples || [];
|
||||
const startTs = dataWindow?.startTimestamp || 0;
|
||||
|
||||
// Calcul RMS glissant
|
||||
const rmsData = useMemo(() => {
|
||||
if (samples.length === 0) return [];
|
||||
const windowSize = 20;
|
||||
const result = [];
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const start = Math.max(0, i - windowSize);
|
||||
const window = samples.slice(start, i + 1);
|
||||
const rms = Math.sqrt(window.reduce((a, b) => a + b*b, 0) / window.length);
|
||||
result.push(rms);
|
||||
}
|
||||
return result;
|
||||
}, [samples]);
|
||||
|
||||
// Formatter pour le X (DD hh:mm:ss)
|
||||
const formatXAxis = (ts: number) => {
|
||||
const d = new Date(ts * 1000);
|
||||
const day = d.getDate().toString().padStart(2, '0');
|
||||
const h = d.getHours().toString().padStart(2, '0');
|
||||
const m = d.getMinutes().toString().padStart(2, '0');
|
||||
const s = d.getSeconds().toString().padStart(2, '0');
|
||||
return `${day} ${h}:${m}:${s}`;
|
||||
};
|
||||
|
||||
const xValues = useMemo(() => {
|
||||
return samples.map((_, i) => formatXAxis(startTs + i / sampleRate));
|
||||
}, [samples, startTs, sampleRate]);
|
||||
|
||||
const plotData: any[] = [];
|
||||
if (samples.length > 0) {
|
||||
if (viewMode === 'waveform') {
|
||||
plotData.push({
|
||||
x: xValues,
|
||||
y: samples,
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
name: 'ADC Brute',
|
||||
line: { color: '#4ade80', width: 1.5 }
|
||||
});
|
||||
} else {
|
||||
plotData.push({
|
||||
x: xValues,
|
||||
y: rmsData,
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
name: 'RMS',
|
||||
line: { color: '#fbbf24', width: 2 }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="sidebar" style={{ width: '450px', background: '#1e293b', borderLeft: '1px solid #334155', display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ padding: '20px', borderBottom: '1px solid #334155' }}>
|
||||
<h2 style={{ margin: 0, color: '#f8fafc' }}>Analyse Node</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '20px' }}>
|
||||
{selectedNode ? (
|
||||
<>
|
||||
<div style={{ marginBottom: '20px', background: '#0f172a', padding: '15px', borderRadius: '8px' }}>
|
||||
<h3 style={{ margin: '0 0 10px 0', color: '#38bdf8' }}>Node b{selectedNode.id}</h3>
|
||||
<p style={{ margin: '5px 0', fontSize: '0.9rem' }}>E: {selectedNode.position?.easting.toFixed(0)} N: {selectedNode.position?.northing.toFixed(0)}</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '5px', marginBottom: '15px' }}>
|
||||
<button onClick={() => setViewMode('waveform')} style={{ flex: 1, padding: '8px', background: viewMode === 'waveform' ? '#3b82f6' : '#334155', border: 'none', color: '#fff', borderRadius: '4px', cursor: 'pointer' }}>Waveform (Brute)</button>
|
||||
<button onClick={() => setViewMode('rms')} style={{ flex: 1, padding: '8px', background: viewMode === 'rms' ? '#3b82f6' : '#334155', border: 'none', color: '#fff', borderRadius: '4px', cursor: 'pointer' }}>RMS</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#94a3b8' }}>Chargement…</div>
|
||||
) : samples.length > 0 ? (
|
||||
<>
|
||||
<div style={{ background: '#000', borderRadius: '8px', overflow: 'hidden', height: '300px' }}>
|
||||
<Plot
|
||||
data={plotData}
|
||||
layout={{
|
||||
autosize: true,
|
||||
height: 300,
|
||||
margin: { t: 10, b: 60, l: 50, r: 10 },
|
||||
paper_bgcolor: 'rgba(0,0,0,0)',
|
||||
plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
font: { color: '#94a3b8', size: 10 },
|
||||
xaxis: {
|
||||
gridcolor: '#1e293b',
|
||||
zerolinecolor: '#334155',
|
||||
tickangle: -45,
|
||||
nticks: 5
|
||||
},
|
||||
yaxis: { gridcolor: '#1e293b', zerolinecolor: '#334155', title: 'Amplitude' }
|
||||
}}
|
||||
config={{ responsive: true, displayModeBar: true }}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
{/* Stats... */}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
241
frontend_src/components/CampaignDocs.tsx
Normal file
241
frontend_src/components/CampaignDocs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
206
frontend_src/components/DataDocumentation.tsx
Normal file
206
frontend_src/components/DataDocumentation.tsx
Normal 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 < 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 < 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>
|
||||
);
|
||||
}
|
||||
151
frontend_src/components/H5Coverage.tsx
Normal file
151
frontend_src/components/H5Coverage.tsx
Normal 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;
|
||||
246
frontend_src/components/H5Dashboard.tsx
Normal file
246
frontend_src/components/H5Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user