up
This commit is contained in:
140
frontend/public/spot-worker.js
Normal file
140
frontend/public/spot-worker.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// spot-worker.js - Web Worker pour traiter les spots
|
||||
|
||||
self.onmessage = function(e) {
|
||||
const { type, data } = e.data;
|
||||
|
||||
switch(type) {
|
||||
case 'FILTER_SPOTS':
|
||||
const filtered = filterSpots(data.spots, data.filters, data.watchlist);
|
||||
self.postMessage({ type: 'FILTERED_SPOTS', data: filtered });
|
||||
break;
|
||||
|
||||
case 'SORT_SPOTS':
|
||||
const sorted = sortSpots(data.spots, data.sortBy, data.sortOrder);
|
||||
self.postMessage({ type: 'SORTED_SPOTS', data: sorted });
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error('Unknown worker message type:', type);
|
||||
}
|
||||
};
|
||||
|
||||
function filterSpots(allSpots, filters, watchlist) {
|
||||
if (!allSpots || !Array.isArray(allSpots)) return [];
|
||||
|
||||
// Si "All" est actif, retourner tous les spots
|
||||
if (filters.showAll) {
|
||||
return allSpots;
|
||||
}
|
||||
|
||||
const bandFiltersActive = filters.band160M || filters.band80M || filters.band60M ||
|
||||
filters.band40M || filters.band30M || filters.band20M || filters.band17M ||
|
||||
filters.band15M || filters.band12M || filters.band10M || filters.band6M;
|
||||
|
||||
const typeFiltersActive = filters.showNewDXCC || filters.showNewBand ||
|
||||
filters.showNewMode || filters.showNewBandMode || filters.showNewSlot ||
|
||||
filters.showWorked || filters.showWatchlist;
|
||||
|
||||
const modeFiltersActive = filters.showDigital || filters.showSSB || filters.showCW;
|
||||
|
||||
return allSpots.filter(spot => {
|
||||
let matchesBand = false;
|
||||
let matchesType = false;
|
||||
let matchesMode = false;
|
||||
|
||||
// Filtres de bande
|
||||
if (bandFiltersActive) {
|
||||
matchesBand = (
|
||||
(filters.band160M && spot.Band === '160M') ||
|
||||
(filters.band80M && spot.Band === '80M') ||
|
||||
(filters.band60M && spot.Band === '60M') ||
|
||||
(filters.band40M && spot.Band === '40M') ||
|
||||
(filters.band30M && spot.Band === '30M') ||
|
||||
(filters.band20M && spot.Band === '20M') ||
|
||||
(filters.band17M && spot.Band === '17M') ||
|
||||
(filters.band15M && spot.Band === '15M') ||
|
||||
(filters.band12M && spot.Band === '12M') ||
|
||||
(filters.band10M && spot.Band === '10M') ||
|
||||
(filters.band6M && spot.Band === '6M')
|
||||
);
|
||||
}
|
||||
|
||||
// Filtres de type
|
||||
if (typeFiltersActive) {
|
||||
if (filters.showWatchlist) {
|
||||
const inWatchlist = watchlist.some(pattern =>
|
||||
spot.DX === pattern || spot.DX.startsWith(pattern)
|
||||
);
|
||||
if (inWatchlist) matchesType = true;
|
||||
}
|
||||
if (filters.showNewDXCC && spot.NewDXCC) matchesType = true;
|
||||
else if (filters.showNewBandMode && spot.NewBand && spot.NewMode && !spot.NewDXCC) matchesType = true;
|
||||
else if (filters.showNewBand && spot.NewBand && !spot.NewMode && !spot.NewDXCC) matchesType = true;
|
||||
else if (filters.showNewMode && spot.NewMode && !spot.NewBand && !spot.NewDXCC) matchesType = true;
|
||||
else if (filters.showNewSlot && spot.NewSlot && !spot.NewDXCC && !spot.NewBand && !spot.NewMode) matchesType = true;
|
||||
else if (filters.showWorked && spot.Worked) matchesType = true;
|
||||
}
|
||||
|
||||
// Filtres de mode
|
||||
if (modeFiltersActive) {
|
||||
const mode = spot.Mode || '';
|
||||
if (filters.showDigital && ['FT8', 'FT4', 'RTTY'].includes(mode)) matchesMode = true;
|
||||
if (filters.showSSB && ['SSB', 'USB', 'LSB'].includes(mode)) matchesMode = true;
|
||||
if (filters.showCW && mode === 'CW') matchesMode = true;
|
||||
}
|
||||
|
||||
// Logique de combinaison des filtres
|
||||
const numActiveFilterTypes = [bandFiltersActive, typeFiltersActive, modeFiltersActive].filter(Boolean).length;
|
||||
|
||||
if (numActiveFilterTypes === 0) return false;
|
||||
if (numActiveFilterTypes === 1) {
|
||||
if (bandFiltersActive) return matchesBand;
|
||||
if (typeFiltersActive) return matchesType;
|
||||
if (modeFiltersActive) return matchesMode;
|
||||
}
|
||||
if (numActiveFilterTypes === 2) {
|
||||
if (bandFiltersActive && typeFiltersActive) return matchesBand && matchesType;
|
||||
if (bandFiltersActive && modeFiltersActive) return matchesBand && matchesMode;
|
||||
if (typeFiltersActive && modeFiltersActive) return matchesType && matchesMode;
|
||||
}
|
||||
if (numActiveFilterTypes === 3) {
|
||||
return matchesBand && matchesType && matchesMode;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function sortSpots(spots, sortBy, sortOrder) {
|
||||
if (!spots || !Array.isArray(spots)) return [];
|
||||
|
||||
const sorted = [...spots].sort((a, b) => {
|
||||
let compareValue = 0;
|
||||
|
||||
switch(sortBy) {
|
||||
case 'dx':
|
||||
compareValue = a.DX.localeCompare(b.DX);
|
||||
break;
|
||||
case 'frequency':
|
||||
compareValue = parseFloat(a.FrequencyMhz) - parseFloat(b.FrequencyMhz);
|
||||
break;
|
||||
case 'band':
|
||||
compareValue = a.Band.localeCompare(b.Band);
|
||||
break;
|
||||
case 'mode':
|
||||
compareValue = a.Mode.localeCompare(b.Mode);
|
||||
break;
|
||||
case 'time':
|
||||
compareValue = a.UTCTime.localeCompare(b.UTCTime);
|
||||
break;
|
||||
default:
|
||||
compareValue = a.ID - b.ID; // Par défaut, tri par ID
|
||||
}
|
||||
|
||||
return sortOrder === 'asc' ? compareValue : -compareValue;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
console.log('Spot Worker initialized');
|
||||
@@ -7,6 +7,9 @@
|
||||
import Sidebar from './components/Sidebar.svelte';
|
||||
import Toast from './components/Toast.svelte';
|
||||
import ErrorBanner from './components/ErrorBanner.svelte';
|
||||
import { spotWorker } from './lib/spotWorker.js';
|
||||
import { spotCache } from './lib/spotCache.js';
|
||||
|
||||
|
||||
// State
|
||||
let spots = [];
|
||||
@@ -68,6 +71,9 @@
|
||||
let maxReconnectAttempts = 10;
|
||||
let isShuttingDown = false;
|
||||
let filterTimeout;
|
||||
let isFiltering = false;
|
||||
let cacheLoaded = false;
|
||||
let notifiedSpots = new Set();
|
||||
|
||||
$: {
|
||||
if (spotFilters.showAll) {
|
||||
@@ -75,8 +81,10 @@
|
||||
} else {
|
||||
if (filterTimeout) clearTimeout(filterTimeout);
|
||||
|
||||
filterTimeout = setTimeout(() => {
|
||||
filteredSpots = applyFilters(spots, spotFilters, watchlist);
|
||||
filterTimeout = setTimeout(async () => {
|
||||
isFiltering = true;
|
||||
filteredSpots = await spotWorker.filterSpots(spots, spotFilters, watchlist);
|
||||
isFiltering = false;
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
@@ -248,29 +256,57 @@
|
||||
errorMessage = 'Unable to connect to server. Please refresh the page.';
|
||||
}
|
||||
};
|
||||
} catch (error) { // ✅ AJOUTER cette ligne
|
||||
} catch (error) {
|
||||
console.error('Error creating WebSocket:', error);
|
||||
wsStatus = 'disconnected';
|
||||
} // ✅ AJOUTER cette ligne
|
||||
}
|
||||
}
|
||||
|
||||
function handleWebSocketMessage(message) {
|
||||
switch (message.type) {
|
||||
case 'stats':
|
||||
stats = message.data;
|
||||
spotCache.saveMetadata('stats', stats).catch(err => console.error('Cache save error:', err));
|
||||
break;
|
||||
case 'spots':
|
||||
spots = message.data || [];
|
||||
const newSpots = message.data || [];
|
||||
|
||||
if (stats.myCallsign && newSpots.length > 0) {
|
||||
newSpots.forEach(spot => {
|
||||
// Vérifier si c'est votre callsign ET qu'on ne l'a pas déjà notifié
|
||||
if (spot.DX === stats.myCallsign && !notifiedSpots.has(spot.ID)) {
|
||||
notifiedSpots.add(spot.ID);
|
||||
showToast(
|
||||
`📢 You were spotted by ${spot.SpotterCallsign} on ${spot.FrequencyMhz} (${spot.Band} ${spot.Mode})`,
|
||||
'mycall'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (notifiedSpots.size > 100) {
|
||||
const arr = Array.from(notifiedSpots);
|
||||
notifiedSpots = new Set(arr.slice(-100));
|
||||
}
|
||||
}
|
||||
|
||||
spots = newSpots;
|
||||
|
||||
if (spots.length > 0) {
|
||||
spotCache.saveSpots(spots).catch(err => console.error('Cache save error:', err));
|
||||
}
|
||||
break;
|
||||
case 'spotters':
|
||||
topSpotters = message.data || [];
|
||||
break;
|
||||
case 'watchlist':
|
||||
// ✅ La watchlist est mise à jour par WebSocket
|
||||
watchlist = message.data || [];
|
||||
spotCache.saveMetadata('watchlist', watchlist).catch(err => console.error('Cache save error:', err));
|
||||
break;
|
||||
case 'log':
|
||||
recentQSOs = message.data || [];
|
||||
if (recentQSOs.length > 0) {
|
||||
spotCache.saveQSOs(recentQSOs).catch(err => console.error('Cache save error:', err));
|
||||
}
|
||||
break;
|
||||
case 'logStats':
|
||||
logStats = message.data || {};
|
||||
@@ -291,7 +327,7 @@
|
||||
toastType = type;
|
||||
setTimeout(() => {
|
||||
toastMessage = '';
|
||||
}, 3000);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function fetchSolarData() {
|
||||
@@ -401,8 +437,44 @@ async function shutdownApp() {
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const savedSoundEnabled = localStorage.getItem('soundEnabled');
|
||||
onMount(async () => {
|
||||
// ✅ Initialiser IndexedDB
|
||||
try {
|
||||
await spotCache.init();
|
||||
|
||||
// ✅ Charger les données du cache immédiatement
|
||||
const cachedSpots = await spotCache.getSpots();
|
||||
if (cachedSpots.length > 0) {
|
||||
spots = cachedSpots;
|
||||
cacheLoaded = true;
|
||||
console.log('📦 Loaded data from cache');
|
||||
}
|
||||
|
||||
// Charger watchlist du cache
|
||||
const cachedWatchlist = await spotCache.getMetadata('watchlist');
|
||||
if (cachedWatchlist) {
|
||||
watchlist = cachedWatchlist;
|
||||
}
|
||||
|
||||
// Charger QSOs du cache
|
||||
const cachedQSOs = await spotCache.getQSOs();
|
||||
if (cachedQSOs.length > 0) {
|
||||
recentQSOs = cachedQSOs;
|
||||
}
|
||||
|
||||
// Charger stats du cache
|
||||
const cachedStats = await spotCache.getMetadata('stats');
|
||||
if (cachedStats) {
|
||||
stats = cachedStats;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize cache:', error);
|
||||
}
|
||||
|
||||
// Initialiser le worker
|
||||
spotWorker.init();
|
||||
|
||||
// Se connecter au WebSocket (qui va mettre à jour avec les données fraîches)
|
||||
connectWebSocket();
|
||||
fetchSolarData();
|
||||
|
||||
@@ -414,6 +486,8 @@ async function shutdownApp() {
|
||||
window.addEventListener('sendSpot', handleSendSpot);
|
||||
|
||||
return () => {
|
||||
spotWorker.terminate();
|
||||
spotCache.close();
|
||||
if (ws) ws.close();
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
clearInterval(solarInterval);
|
||||
@@ -436,6 +510,7 @@ async function shutdownApp() {
|
||||
{stats}
|
||||
{solarData}
|
||||
{wsStatus}
|
||||
{cacheLoaded}
|
||||
on:shutdown={shutdownApp}
|
||||
/>
|
||||
|
||||
@@ -448,6 +523,7 @@ async function shutdownApp() {
|
||||
{spotFilters}
|
||||
{spots}
|
||||
{watchlist}
|
||||
{isFiltering}
|
||||
on:toggleFilter={(e) => toggleFilter(e.detail)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
export let spotFilters;
|
||||
export let spots;
|
||||
export let watchlist;
|
||||
export let isFiltering = false;
|
||||
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -76,9 +78,20 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-2 border border-slate-700/50 mb-3">
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
<span class="text-xs font-bold text-slate-400 mr-2">TYPE:</span>
|
||||
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-2 border border-slate-700/50 mb-3">
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
<!-- ✅ AJOUTER le spinner ici -->
|
||||
{#if isFiltering}
|
||||
<div class="inline-flex items-center gap-2 text-xs text-blue-400 mr-3">
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Filtering...
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<span class="text-xs font-bold text-slate-400 mr-2">TYPE:</span>
|
||||
|
||||
<button
|
||||
on:click={() => dispatch('toggleFilter', 'showAll')}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
export let stats;
|
||||
export let solarData;
|
||||
export let wsStatus;
|
||||
export let cacheLoaded = false;
|
||||
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -100,6 +102,15 @@
|
||||
Flex
|
||||
</span>
|
||||
|
||||
{#if cacheLoaded}
|
||||
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold bg-purple-500/20 text-purple-400">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
|
||||
</svg>
|
||||
Cached
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
on:click={() => dispatch('shutdown')}
|
||||
class="px-3 py-1.5 text-xs bg-red-600 hover:bg-red-700 rounded transition-colors flex items-center gap-1">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import VirtualList from 'svelte-virtual-list';
|
||||
|
||||
export let spots;
|
||||
export let watchlist;
|
||||
@@ -7,6 +8,9 @@
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let container;
|
||||
const itemHeight = 45;
|
||||
|
||||
function handleSpotClick(spot) {
|
||||
dispatch('clickSpot', {
|
||||
callsign: spot.DX,
|
||||
@@ -52,51 +56,54 @@
|
||||
<h2 class="text-lg font-bold">Recent Spots (<span>{spots.length}</span>)</h2>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-900/50 sticky top-0">
|
||||
<tr class="text-left text-xs text-slate-400">
|
||||
<th class="p-2 font-semibold">DX</th>
|
||||
<th class="p-2 font-semibold">Freq</th>
|
||||
<th class="p-2 font-semibold">Band</th>
|
||||
<th class="p-2 font-semibold">Mode</th>
|
||||
<th class="p-2 font-semibold">Spotter</th>
|
||||
<th class="p-2 font-semibold">Time</th>
|
||||
<th class="p-2 font-semibold">Country</th>
|
||||
<th class="p-2 font-semibold">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each spots as spot (spot.ID)}
|
||||
<tr class="border-b border-slate-700/30 hover:bg-slate-700/30 transition-colors text-sm">
|
||||
<td class="p-2">
|
||||
<button
|
||||
class="font-bold text-blue-400 dx-callsign"
|
||||
on:click={() => handleSpotClick(spot)}
|
||||
title="Click to send to Log4OM and tune radio">
|
||||
{spot.DX}
|
||||
</button>
|
||||
</td>
|
||||
<td class="p-2 font-mono text-xs">{spot.FrequencyMhz}</td>
|
||||
<td class="p-2">
|
||||
<span class="px-1.5 py-0.5 bg-slate-700/50 rounded text-xs">{spot.Band}</span>
|
||||
</td>
|
||||
<td class="p-2">
|
||||
<span class="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded text-xs">{spot.Mode}</span>
|
||||
</td>
|
||||
<td class="p-2 text-slate-300 text-xs">{spot.SpotterCallsign}</td>
|
||||
<td class="p-2 text-slate-400 text-xs">{spot.UTCTime}</td>
|
||||
<td class="p-2 text-slate-400 text-xs">{spot.CountryName || 'N/A'}</td>
|
||||
<td class="p-2">
|
||||
{#if getStatusLabel(spot)}
|
||||
<span class="px-1.5 py-0.5 rounded text-xs font-semibold border {getPriorityColor(spot)}">
|
||||
{getStatusLabel(spot)}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Header fixe -->
|
||||
<div class="bg-slate-900/50 flex-shrink-0">
|
||||
<div class="flex text-left text-xs text-slate-400 font-semibold">
|
||||
<div class="p-2" style="width: 12%;">DX</div>
|
||||
<div class="p-2" style="width: 12%;">Freq</div>
|
||||
<div class="p-2" style="width: 8%;">Band</div>
|
||||
<div class="p-2" style="width: 8%;">Mode</div>
|
||||
<div class="p-2" style="width: 12%;">Spotter</div>
|
||||
<div class="p-2" style="width: 8%;">Time</div>
|
||||
<div class="p-2" style="width: 25%;">Country</div>
|
||||
<div class="p-2" style="width: 15%;">Status</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste virtualisée -->
|
||||
<div class="flex-1 overflow-hidden" bind:this={container}>
|
||||
<VirtualList items={spots} {itemHeight} let:item>
|
||||
<div class="flex border-b border-slate-700/30 hover:bg-slate-700/30 transition-colors text-sm" style="height: {itemHeight}px;">
|
||||
<div class="p-2 flex items-center" style="width: 12%;">
|
||||
<button
|
||||
class="font-bold text-blue-400 hover:text-blue-300 transition-colors truncate w-full text-left"
|
||||
on:click={() => handleSpotClick(item)}
|
||||
title="Click to send to Log4OM and tune radio">
|
||||
{item.DX}
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-2 flex items-center font-mono text-xs" style="width: 12%;">{item.FrequencyMhz}</div>
|
||||
<div class="p-2 flex items-center" style="width: 8%;">
|
||||
<span class="px-1.5 py-0.5 bg-slate-700/50 rounded text-xs">{item.Band}</span>
|
||||
</div>
|
||||
<div class="p-2 flex items-center" style="width: 8%;">
|
||||
<span class="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded text-xs">{item.Mode}</span>
|
||||
</div>
|
||||
<div class="p-2 flex items-center text-slate-300 text-xs truncate" style="width: 12%;" title={item.SpotterCallsign}>
|
||||
{item.SpotterCallsign}
|
||||
</div>
|
||||
<div class="p-2 flex items-center text-slate-400 text-xs" style="width: 8%;">{item.UTCTime}</div>
|
||||
<div class="p-2 flex items-center text-slate-400 text-xs truncate" style="width: 25%;" title={item.CountryName || 'N/A'}>
|
||||
{item.CountryName || 'N/A'}
|
||||
</div>
|
||||
<div class="p-2 flex items-center" style="width: 15%;">
|
||||
{#if getStatusLabel(item)}
|
||||
<span class="px-1.5 py-0.5 rounded text-xs font-semibold border {getPriorityColor(item)} truncate">
|
||||
{getStatusLabel(item)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</VirtualList>
|
||||
</div>
|
||||
</div>
|
||||
@@ -8,7 +8,8 @@
|
||||
warning: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>`,
|
||||
info: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>`,
|
||||
milestone: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path>`,
|
||||
band: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path>`
|
||||
band: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path>`,
|
||||
mycall: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path>`
|
||||
};
|
||||
|
||||
const colors = {
|
||||
@@ -17,7 +18,8 @@
|
||||
warning: 'bg-orange-500',
|
||||
info: 'bg-blue-500',
|
||||
milestone: 'bg-gradient-to-r from-purple-500 to-pink-500',
|
||||
band: 'bg-gradient-to-r from-orange-500 to-amber-500'
|
||||
band: 'bg-gradient-to-r from-orange-500 to-amber-500',
|
||||
mycall: 'bg-gradient-to-r from-red-500 to-pink-500'
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
229
frontend/src/lib/spotCache.js
Normal file
229
frontend/src/lib/spotCache.js
Normal file
@@ -0,0 +1,229 @@
|
||||
// spotCache.js - Gestionnaire de cache IndexedDB pour les spots
|
||||
|
||||
class SpotCache {
|
||||
constructor() {
|
||||
this.dbName = 'FlexDXClusterDB';
|
||||
this.version = 1;
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.version);
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('IndexedDB failed to open');
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
console.log('✅ IndexedDB initialized');
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
|
||||
// Store pour les spots
|
||||
if (!db.objectStoreNames.contains('spots')) {
|
||||
const spotStore = db.createObjectStore('spots', { keyPath: 'ID' });
|
||||
spotStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
spotStore.createIndex('band', 'Band', { unique: false });
|
||||
console.log('Created spots store');
|
||||
}
|
||||
|
||||
// Store pour les métadonnées (stats, watchlist, etc.)
|
||||
if (!db.objectStoreNames.contains('metadata')) {
|
||||
db.createObjectStore('metadata', { keyPath: 'key' });
|
||||
console.log('Created metadata store');
|
||||
}
|
||||
|
||||
// Store pour les QSOs récents
|
||||
if (!db.objectStoreNames.contains('qsos')) {
|
||||
const qsoStore = db.createObjectStore('qsos', { keyPath: 'id', autoIncrement: true });
|
||||
qsoStore.createIndex('callsign', 'callsign', { unique: false });
|
||||
console.log('Created qsos store');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Sauvegarder les spots
|
||||
async saveSpots(spots) {
|
||||
if (!this.db || !spots || spots.length === 0) return;
|
||||
|
||||
try {
|
||||
const transaction = this.db.transaction(['spots'], 'readwrite');
|
||||
const store = transaction.objectStore('spots');
|
||||
|
||||
// Vider d'abord le store
|
||||
await store.clear();
|
||||
|
||||
// Ajouter tous les spots avec un timestamp
|
||||
const timestamp = Date.now();
|
||||
spots.forEach(spot => {
|
||||
store.put({ ...spot, timestamp });
|
||||
});
|
||||
|
||||
await this.waitForTransaction(transaction);
|
||||
console.log(`✅ Saved ${spots.length} spots to cache`);
|
||||
} catch (error) {
|
||||
console.error('Error saving spots:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer les spots du cache
|
||||
async getSpots(maxAge = 5 * 60 * 1000) { // 5 minutes par défaut
|
||||
if (!this.db) return [];
|
||||
|
||||
try {
|
||||
const transaction = this.db.transaction(['spots'], 'readonly');
|
||||
const store = transaction.objectStore('spots');
|
||||
const request = store.getAll();
|
||||
|
||||
const spots = await this.waitForRequest(request);
|
||||
|
||||
// Vérifier l'âge du cache
|
||||
if (spots.length > 0) {
|
||||
const cacheAge = Date.now() - spots[0].timestamp;
|
||||
if (cacheAge > maxAge) {
|
||||
console.log('Cache too old, clearing...');
|
||||
await this.clearSpots();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📦 Loaded ${spots.length} spots from cache`);
|
||||
return spots;
|
||||
} catch (error) {
|
||||
console.error('Error getting spots:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer les spots du cache
|
||||
async clearSpots() {
|
||||
if (!this.db) return;
|
||||
|
||||
try {
|
||||
const transaction = this.db.transaction(['spots'], 'readwrite');
|
||||
const store = transaction.objectStore('spots');
|
||||
await store.clear();
|
||||
await this.waitForTransaction(transaction);
|
||||
console.log('🗑️ Cleared spots cache');
|
||||
} catch (error) {
|
||||
console.error('Error clearing spots:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Sauvegarder les métadonnées (stats, watchlist, etc.)
|
||||
async saveMetadata(key, value) {
|
||||
if (!this.db) return;
|
||||
|
||||
try {
|
||||
const transaction = this.db.transaction(['metadata'], 'readwrite');
|
||||
const store = transaction.objectStore('metadata');
|
||||
await store.put({ key, value, timestamp: Date.now() });
|
||||
await this.waitForTransaction(transaction);
|
||||
} catch (error) {
|
||||
console.error('Error saving metadata:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer les métadonnées
|
||||
async getMetadata(key) {
|
||||
if (!this.db) return null;
|
||||
|
||||
try {
|
||||
const transaction = this.db.transaction(['metadata'], 'readonly');
|
||||
const store = transaction.objectStore('metadata');
|
||||
const request = store.get(key);
|
||||
const result = await this.waitForRequest(request);
|
||||
return result ? result.value : null;
|
||||
} catch (error) {
|
||||
console.error('Error getting metadata:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Sauvegarder les QSOs récents
|
||||
async saveQSOs(qsos) {
|
||||
if (!this.db || !qsos || qsos.length === 0) return;
|
||||
|
||||
try {
|
||||
const transaction = this.db.transaction(['qsos'], 'readwrite');
|
||||
const store = transaction.objectStore('qsos');
|
||||
|
||||
// Vider d'abord
|
||||
await store.clear();
|
||||
|
||||
// Ajouter les QSOs
|
||||
qsos.forEach((qso, index) => {
|
||||
store.put({ ...qso, id: index + 1 });
|
||||
});
|
||||
|
||||
await this.waitForTransaction(transaction);
|
||||
console.log(`✅ Saved ${qsos.length} QSOs to cache`);
|
||||
} catch (error) {
|
||||
console.error('Error saving QSOs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer les QSOs du cache
|
||||
async getQSOs() {
|
||||
if (!this.db) return [];
|
||||
|
||||
try {
|
||||
const transaction = this.db.transaction(['qsos'], 'readonly');
|
||||
const store = transaction.objectStore('qsos');
|
||||
const request = store.getAll();
|
||||
const qsos = await this.waitForRequest(request);
|
||||
console.log(`📦 Loaded ${qsos.length} QSOs from cache`);
|
||||
return qsos;
|
||||
} catch (error) {
|
||||
console.error('Error getting QSOs:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Utilitaires
|
||||
waitForRequest(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
waitForTransaction(transaction) {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Fermer la base de données
|
||||
close() {
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = null;
|
||||
console.log('IndexedDB closed');
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer complètement la base
|
||||
async deleteDatabase() {
|
||||
this.close();
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase(this.dbName);
|
||||
request.onsuccess = () => {
|
||||
console.log('🗑️ Database deleted');
|
||||
resolve();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const spotCache = new SpotCache();
|
||||
92
frontend/src/lib/spotWorker.js
Normal file
92
frontend/src/lib/spotWorker.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// spotWorker.js - Wrapper pour gérer le Web Worker
|
||||
|
||||
class SpotWorkerManager {
|
||||
constructor() {
|
||||
this.worker = null;
|
||||
this.callbacks = new Map();
|
||||
this.messageId = 0;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.worker) return;
|
||||
|
||||
try {
|
||||
this.worker = new Worker('/spot-worker.js');
|
||||
|
||||
this.worker.onmessage = (e) => {
|
||||
const { type, data, messageId } = e.data;
|
||||
|
||||
// Appeler le callback correspondant si présent
|
||||
if (messageId && this.callbacks.has(messageId)) {
|
||||
const callback = this.callbacks.get(messageId);
|
||||
callback(data);
|
||||
this.callbacks.delete(messageId);
|
||||
}
|
||||
};
|
||||
|
||||
this.worker.onerror = (error) => {
|
||||
console.error('Worker error:', error);
|
||||
};
|
||||
|
||||
console.log('✅ Spot Worker initialized');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize worker:', error);
|
||||
}
|
||||
}
|
||||
|
||||
filterSpots(spots, filters, watchlist) {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.worker) {
|
||||
console.warn('Worker not initialized, filtering on main thread');
|
||||
resolve(spots);
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = ++this.messageId;
|
||||
|
||||
this.callbacks.set(messageId, (filteredSpots) => {
|
||||
resolve(filteredSpots);
|
||||
});
|
||||
|
||||
this.worker.postMessage({
|
||||
type: 'FILTER_SPOTS',
|
||||
messageId,
|
||||
data: { spots, filters, watchlist }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
sortSpots(spots, sortBy = 'id', sortOrder = 'desc') {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.worker) {
|
||||
console.warn('Worker not initialized, sorting on main thread');
|
||||
resolve(spots);
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = ++this.messageId;
|
||||
|
||||
this.callbacks.set(messageId, (sortedSpots) => {
|
||||
resolve(sortedSpots);
|
||||
});
|
||||
|
||||
this.worker.postMessage({
|
||||
type: 'SORT_SPOTS',
|
||||
messageId,
|
||||
data: { spots, sortBy, sortOrder }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
terminate() {
|
||||
if (this.worker) {
|
||||
this.worker.terminate();
|
||||
this.worker = null;
|
||||
this.callbacks.clear();
|
||||
console.log('Worker terminated');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const spotWorker = new SpotWorkerManager();
|
||||
@@ -1 +1 @@
|
||||
["H44MS","5X2I","PY0FB","PY0FBS","XT2AW","ZL7IO","YJ0CA","FW5K","J38","E6AD","E51MWA","PJ6Y","5J0EA","5K0UA","VP2M","5X1XA","C5R","C5LT","EL2BG","4X6TT","V85NPV","YI1MB","C21TS","XV9","TJ1GD","PZ5RA"]
|
||||
["H44MS","5X2I","PY0FB","PY0FBS","XT2AW","ZL7IO","YJ0CA","FW5K","J38","E6AD","E51MWA","PJ6Y","5J0EA","5K0UA","VP2M","5X1XA","C5R","C5LT","EL2BG","4X6TT","V85NPV","YI1MB","C21TS","XV9","TJ1GD"]
|
||||
Reference in New Issue
Block a user