347 lines
20 KiB
Plaintext
347 lines
20 KiB
Plaintext
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;
|