diff --git a/package-lock.json b/package-lock.json index da4f438b..ae50ce07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "15.5.8", "license": "MIT", "dependencies": { + "@hamset/maidenhead-locator": "^0.2.1", "axios": "^1.6.2", "compression": "^1.7.4", "cors": "^2.8.5", @@ -1331,6 +1332,12 @@ "node": ">=12" } }, + "node_modules/@hamset/maidenhead-locator": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@hamset/maidenhead-locator/-/maidenhead-locator-0.2.1.tgz", + "integrity": "sha512-vE6mLwKrqIDu6ldepKnzv7uXCrHn9GpBIW9/T6P+STb4gwbXJ3ok0v8PSje5u8QG47AUzvlMflMkX1/KVpyD3g==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", diff --git a/package.json b/package.json index 57678d63..e9748ebe 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "prepare": "husky || true" }, "dependencies": { + "@hamset/maidenhead-locator": "^0.2.1", "axios": "^1.6.2", "compression": "^1.7.4", "cors": "^2.8.5", diff --git a/scripts/generate-translations.js b/scripts/generate-translations.js index 80d7d256..0daabdb4 100644 --- a/scripts/generate-translations.js +++ b/scripts/generate-translations.js @@ -31,10 +31,22 @@ const universal = { 'weather.unit.mi': 'mi', 'weather.unit.mph': 'mph', // Wind directions are universal abbreviations - 'weather.wind.N': 'N', 'weather.wind.NNE': 'NNE', 'weather.wind.NE': 'NE', 'weather.wind.ENE': 'ENE', - 'weather.wind.E': 'E', 'weather.wind.ESE': 'ESE', 'weather.wind.SE': 'SE', 'weather.wind.SSE': 'SSE', - 'weather.wind.S': 'S', 'weather.wind.SSW': 'SSW', 'weather.wind.SW': 'SW', 'weather.wind.WSW': 'WSW', - 'weather.wind.W': 'W', 'weather.wind.WNW': 'WNW', 'weather.wind.NW': 'NW', 'weather.wind.NNW': 'NNW', + 'weather.wind.N': 'N', + 'weather.wind.NNE': 'NNE', + 'weather.wind.NE': 'NE', + 'weather.wind.ENE': 'ENE', + 'weather.wind.E': 'E', + 'weather.wind.ESE': 'ESE', + 'weather.wind.SE': 'SE', + 'weather.wind.SSE': 'SSE', + 'weather.wind.S': 'S', + 'weather.wind.SSW': 'SSW', + 'weather.wind.SW': 'SW', + 'weather.wind.WSW': 'WSW', + 'weather.wind.W': 'W', + 'weather.wind.WNW': 'WNW', + 'weather.wind.NW': 'NW', + 'weather.wind.NNW': 'NNW', // Plugin layer names that are proper nouns / brand names 'plugins.layers.wspr.name': 'WSPR', 'plugins.layers.rbn.title': 'RBN', @@ -226,7 +238,8 @@ const translations = { 'station.settings.dx.custom.port': 'Port', 'station.settings.dx.custom.port.placeholder': '7300', 'station.settings.dx.custom.title': '📡 Eigener Telnet-Server', - 'station.settings.dx.custom.warning': '⚠️ Eigener Telnet erfordert Selbsthosting (Pi/lokal). Cloud-Hosting (Railway/openhamclock.app) blockiert ausgehende Telnet-Verbindungen.', + 'station.settings.dx.custom.warning': + '⚠️ Eigener Telnet erfordert Selbsthosting (Pi/lokal). Cloud-Hosting (Railway/openhamclock.app) blockiert ausgehende Telnet-Verbindungen.', 'station.settings.headerSize': 'Rufzeichengröße', 'station.settings.layers.noLayers': 'Keine Kartenebenen verfügbar', 'station.settings.layers.opacity': 'Deckkraft', @@ -249,14 +262,16 @@ const translations = { 'station.settings.tab3.title': '⛊ Satelliten', 'station.settings.timezone.auto': 'Auto (Browser-Standard)', 'station.settings.timezone.currentDefault': ' Aktuell wird der Browser-Standard verwendet.', - 'station.settings.timezone.describe': 'Setzen Sie dies, wenn Ihre Ortszeit falsch angezeigt wird (z.B. gleich wie UTC). Datenschutzbrowser wie Librewolf können Ihre Zeitzone verschleiern.', + 'station.settings.timezone.describe': + 'Setzen Sie dies, wenn Ihre Ortszeit falsch angezeigt wird (z.B. gleich wie UTC). Datenschutzbrowser wie Librewolf können Ihre Zeitzone verschleiern.', 'station.settings.timezone.group.africa': 'Afrika', 'station.settings.timezone.group.asiaPacific': 'Asien & Pazifik', 'station.settings.timezone.group.europe': 'Europa', 'station.settings.timezone.group.northAmerica': 'Nordamerika', 'station.settings.timezone.group.other': 'Sonstige', 'station.settings.timezone.group.southAmerica': 'Südamerika', - 'station.settings.tip.env': '💡 Tipp: Für permanente Konfiguration .env.example nach .env kopieren und CALLSIGN und LOCATOR setzen', + 'station.settings.tip.env': + '💡 Tipp: Für permanente Konfiguration .env.example nach .env kopieren und CALLSIGN und LOCATOR setzen', 'weather.clouds': '☁️ Wolken', 'weather.condition.0': 'Klarer Himmel', 'weather.condition.1': 'Überwiegend klar', @@ -307,7 +322,8 @@ const translations = { es: { 'plugins.layers.floods.description': 'Inundaciones y tormentas severas activas en todo el mundo vía NASA EONET', 'plugins.layers.floods.name': 'Inundaciones y Tormentas', - 'plugins.layers.wildfires.description': 'Incendios forestales activos en todo el mundo vía detección satelital NASA EONET', + 'plugins.layers.wildfires.description': + 'Incendios forestales activos en todo el mundo vía detección satelital NASA EONET', 'plugins.layers.wildfires.name': 'Incendios Forestales', }, @@ -389,7 +405,7 @@ const translations = { 'plugins.layers.wspr.veryWeak': 'Très faible (< -20 dB)', 'plugins.layers.wspr.weak': 'Faible (-20 à -10 dB)', 'plugins.layers.wxradar.attribution': 'Données météo © Iowa State University Mesonet', - 'plugins.layers.wxradar.description': 'Superposition radar météo NEXRAD pour l\'Amérique du Nord', + 'plugins.layers.wxradar.description': "Superposition radar météo NEXRAD pour l'Amérique du Nord", 'plugins.layers.wxradar.name': 'Radar météo', 'propagation.day': 'Jour', 'propagation.estimated': 'estimé', @@ -415,7 +431,8 @@ const translations = { 'station.settings.dx.custom.port': 'Port', 'station.settings.dx.custom.port.placeholder': '7300', 'station.settings.dx.custom.title': '📡 Serveur Telnet personnalisé', - 'station.settings.dx.custom.warning': '⚠️ Le telnet personnalisé nécessite un hébergement local (Pi/local). L\'hébergement cloud (Railway/openhamclock.app) bloque les connexions telnet sortantes.', + 'station.settings.dx.custom.warning': + "⚠️ Le telnet personnalisé nécessite un hébergement local (Pi/local). L'hébergement cloud (Railway/openhamclock.app) bloque les connexions telnet sortantes.", 'station.settings.layers.noLayers': 'Aucune couche disponible', 'station.settings.layers.opacity': 'Opacité', 'station.settings.layers.title': 'Couches de carte', @@ -459,7 +476,7 @@ const translations = { 'weather.humidity': '💧 Humidité', 'weather.pressure': '🔵 Pression', 'weather.switchUnit': 'Passer en °{{unit}}', - 'weather.today': 'Aujourd\'hui', + 'weather.today': "Aujourd'hui", 'weather.uv': '☀️ UV', 'weather.visibility': '👁️ Visibilité', 'weather.wind': '💨 Vent', @@ -500,7 +517,11 @@ function applyTranslations(langCode, newTranslations) { // Sort keys alphabetically for consistency const sorted = {}; - Object.keys(merged).sort().forEach(k => { sorted[k] = merged[k]; }); + Object.keys(merged) + .sort() + .forEach((k) => { + sorted[k] = merged[k]; + }); fs.writeFileSync(filePath, JSON.stringify(sorted, null, 2) + '\n', 'utf8'); return added; @@ -525,9 +546,9 @@ for (const lang of universalOnly) { // Final report console.log('\n--- Coverage After ---'); -for (const lang of ['de','es','fr','it','ja','ko','ms','nl','pt','sl']) { +for (const lang of ['de', 'es', 'fr', 'it', 'ja', 'ko', 'ms', 'nl', 'pt', 'sl']) { const data = JSON.parse(fs.readFileSync(path.join(LANG_DIR, lang + '.json'), 'utf8')); const count = Object.keys(data).length; - const pct = Math.round(count / enKeys.length * 100); + const pct = Math.round((count / enKeys.length) * 100); console.log(`${lang.toUpperCase().padEnd(4)} ${count}/${enKeys.length} = ${pct}%`); } diff --git a/src/App.jsx b/src/App.jsx index 33245af5..9ce86149 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -47,6 +47,7 @@ import useLocalInstall from './hooks/app/useLocalInstall'; import useVersionCheck from './hooks/app/useVersionCheck'; import WhatsNew from './components/WhatsNew.jsx'; import { initCtyLookup } from './utils/ctyLookup.js'; +import ActivateFilterManager from './components/ActivateFilterManager.jsx'; // Load DXCC entity database on app startup (non-blocking) initCtyLookup(); @@ -60,6 +61,10 @@ const App = () => { const [showSettings, setShowSettings] = useState(false); const [showDXFilters, setShowDXFilters] = useState(false); const [showPSKFilters, setShowPSKFilters] = useState(false); + const [showPotaFilters, setShowPotaFilters] = useState(false); + const [showSotaFilters, setShowSotaFilters] = useState(false); + const [showWwffFilters, setShowWwffFilters] = useState(false); + const [showWwbotaFilters, setShowWwbotaFilters] = useState(false); const [layoutResetKey, setLayoutResetKey] = useState(0); const [, setBandColorChangeVersion] = useState(0); const [updateInProgress, setUpdateInProgress] = useState(false); @@ -136,9 +141,13 @@ const App = () => { toggleDXPaths, toggleDXLabels, togglePOTA, + togglePOTALabels, toggleWWFF, + toggleWWFFLabels, toggleSOTA, + toggleSOTALabels, toggleWWBOTA, + toggleWWBOTALabels, toggleSatellites, togglePSKReporter, toggleWSJTX, @@ -146,7 +155,22 @@ const App = () => { toggleAPRS, } = useMapLayers(); - const { dxFilters, setDxFilters, pskFilters, setPskFilters, mapBandFilter, setMapBandFilter } = useFilters(); + const { + dxFilters, + setDxFilters, + pskFilters, + setPskFilters, + mapBandFilter, + setMapBandFilter, + potaFilters, + setPotaFilters, + sotaFilters, + setSotaFilters, + wwffFilters, + setWwffFilters, + wwbotaFilters, + setWwbotaFilters, + } = useFilters(); const { isFullscreen, handleFullscreenToggle } = useFullscreen(); const { wakeLockStatus } = useScreenWakeLock(config); @@ -222,6 +246,38 @@ const App = () => { }); }, [pskReporter.txReports, pskReporter.rxReports, pskFilters]); + function ActivateFilter(spots, filters) { + // console.log('[ActivateFilters] filters is ',filters); + if (!filters?.bands?.length && !filters?.grids?.length && !filters?.modes?.length) { + return spots.data; + } + return spots.data.filter((spot) => { + if (filters?.bands?.length && !filters.bands.includes(spot.band)) return false; + if (filters?.modes?.length && !filters.modes.includes(spot.mode)) return false; + if (filters?.grids?.length) { + const gridPrefix = spot.grid.substring(0, 2).toUpperCase(); + if (!filters.grids.includes(gridPrefix)) return false; + } + return true; + }); + } + + const filteredPotaSpots = useMemo(() => { + return ActivateFilter(potaSpots, potaFilters); + }, [potaSpots, potaFilters]); + + const filteredWwffSpots = useMemo(() => { + return ActivateFilter(wwffSpots, wwffFilters); + }, [wwffSpots, wwffFilters]); + + const filteredSotaSpots = useMemo(() => { + return ActivateFilter(sotaSpots, sotaFilters); + }, [sotaSpots, sotaFilters]); + + const filteredWwbotaSpots = useMemo(() => { + return ActivateFilter(wwbotaSpots, wwbotaFilters); + }, [wwbotaSpots, wwbotaFilters]); + const wsjtxMapSpots = useMemo(() => { // Apply same age filter as panel (stored in localStorage) let ageMinutes = 30; @@ -278,6 +334,10 @@ const App = () => { setShowSettings, setShowDXFilters, setShowPSKFilters, + setShowPotaFilters, + setShowSotaFilters, + setShowWwffFilters, + setShowWwbotaFilters, handleUpdateClick, updateInProgress, isLocalInstall, @@ -297,9 +357,13 @@ const App = () => { propagation, dxClusterData, potaSpots, + filteredPotaSpots, wwffSpots, + filteredWwffSpots, sotaSpots, + filteredSotaSpots, wwbotaSpots, + filteredWwbotaSpots, mySpots, dxpeditions, contests, @@ -315,13 +379,25 @@ const App = () => { setMapBandFilter, pskFilters, setPskFilters, + potaFilters, + setPotaFilters, + sotaFilters, + setSotaFilters, + wwffFilters, + setWwffFilters, + wwbotaFilters, + setWwbotaFilters, mapLayers, toggleDXPaths, toggleDXLabels, togglePOTA, + togglePOTALabels, toggleWWFF, + toggleWWFFLabels, toggleSOTA, + toggleSOTALabels, toggleWWBOTA, + toggleWWBOTALabels, toggleSatellites, togglePSKReporter, toggleWSJTX, @@ -384,6 +460,34 @@ const App = () => { isOpen={showPSKFilters} onClose={() => setShowPSKFilters(false)} /> + setShowPotaFilters(false)} + /> + setShowSotaFilters(false)} + /> + setShowWwffFilters(false)} + /> + setShowWwbotaFilters(false)} + /> ); diff --git a/src/DockableApp.jsx b/src/DockableApp.jsx index 80b1ef1d..92251696 100644 --- a/src/DockableApp.jsx +++ b/src/DockableApp.jsx @@ -88,9 +88,13 @@ export const DockableApp = ({ // Spots & data dxClusterData, potaSpots, + filteredPotaSpots, wwffSpots, + filteredWwffSpots, sotaSpots, + filteredSotaSpots, wwbotaSpots, + filteredWwbotaSpots, mySpots, dxpeditions, contests, @@ -110,6 +114,14 @@ export const DockableApp = ({ pskFilters, setShowDXFilters, setShowPSKFilters, + potaFilters, + setShowPotaFilters, + sotaFilters, + setShowSotaFilters, + wwffFilters, + setShowWwffFilters, + wwbotaFilters, + setShowWwbotaFilters, // Map layers mapLayers, @@ -402,11 +414,7 @@ export const DockableApp = ({ - + ); @@ -506,13 +514,7 @@ export const DockableApp = ({ - {showDxWeather && ( - - )} + {showDxWeather && } ); }; @@ -554,10 +556,10 @@ export const DockableApp = ({ onDXChange={handleDXChange} dxLocked={dxLocked} onHoverSpot={setHoveredSpot} - potaSpots={potaSpots.data} - wwffSpots={wwffSpots.data} - sotaSpots={sotaSpots.data} - wwbotaSpots={wwbotaSpots.data} + potaSpots={filteredPotaSpots ? filteredPotaSpots : potaSpots.data} + wwffSpots={filteredWwffSpots ? filteredWwffSpots : wwffSpots.data} + sotaSpots={filteredSotaSpots ? filteredSotaSpots : sotaSpots.data} + wwbotaSpots={filteredWwbotaSpots ? filteredWwbotaSpots : wwbotaSpots.data} mySpots={mySpots.data} dxPaths={dxClusterData.paths} dxFilters={dxFilters} @@ -762,6 +764,9 @@ export const DockableApp = ({ showLabelsOnMap={mapLayersEff.showPOTALabels} onToggleLabelsOnMap={togglePOTALabelsEff} onSpotClick={handleSpotClick} + filters={potaFilters} + onOpenFilters={() => setShowPotaFilters(true)} + filteredData={filteredPotaSpots} /> ); break; @@ -779,6 +784,9 @@ export const DockableApp = ({ showLabelsOnMap={mapLayersEff.showWWFFLabels} onToggleLabelsOnMap={toggleWWFFLabelsEff} onSpotClick={handleSpotClick} + filters={wwffFilters} + onOpenFilters={() => setShowWwffFilters(true)} + filteredData={filteredWwffSpots} /> ); break; @@ -796,6 +804,9 @@ export const DockableApp = ({ showLabelsOnMap={mapLayersEff.showSOTALabels} onToggleLabelsOnMap={toggleSOTALabelsEff} onSpotClick={handleSpotClick} + filters={sotaFilters} + onOpenFilters={() => setShowSotaFilters(true)} + filteredData={filteredSotaSpots} /> ); break; @@ -813,6 +824,9 @@ export const DockableApp = ({ showLabelsOnMap={mapLayersEff.showWWBOTALabels} onToggleLabelsOnMap={toggleWWBOTALabelsEff} onSpotClick={handleSpotClick} + filters={wwbotaFilters} + onOpenFilters={() => setShowWwbotaFilters(true)} + filteredData={filteredWwbotaSpots} /> ); break; @@ -846,11 +860,7 @@ export const DockableApp = ({ ); case 'ambient': - content = ( - - ); + content = ; break; case 'rig-control': @@ -1254,4 +1264,4 @@ export const DockableApp = ({ ); }; -export default DockableApp; \ No newline at end of file +export default DockableApp; diff --git a/src/components/APRSPanel.jsx b/src/components/APRSPanel.jsx index 122ea776..53a98856 100644 --- a/src/components/APRSPanel.jsx +++ b/src/components/APRSPanel.jsx @@ -7,13 +7,7 @@ import React, { useState, useMemo, useCallback } from 'react'; import CallsignLink from './CallsignLink.jsx'; import { getBandColor } from '../utils/bandColors.js'; -const APRSPanel = ({ - aprsData, - showOnMap, - onToggleMap, - onSpotClick, - onHoverSpot, -}) => { +const APRSPanel = ({ aprsData, showOnMap, onToggleMap, onSpotClick, onHoverSpot }) => { const { filteredStations = [], stations = [], @@ -41,8 +35,7 @@ const APRSPanel = ({ if (!search.trim()) return filteredStations; const q = search.toUpperCase(); return filteredStations.filter( - (s) => - s.call?.includes(q) || s.ssid?.includes(q) || s.comment?.toUpperCase().includes(q), + (s) => s.call?.includes(q) || s.ssid?.includes(q) || s.comment?.toUpperCase().includes(q), ); }, [filteredStations, search]); @@ -62,17 +55,26 @@ const APRSPanel = ({ } }, [addCallInput, addCallTarget, addCallToGroup]); - const formatAge = (minutes) => - minutes < 1 ? 'now' : minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`; + const formatAge = (minutes) => (minutes < 1 ? 'now' : minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`); if (!aprsEnabled) { return (
📍
APRS Not Enabled
-
Add APRS_ENABLED=true to your .env file and restart the server.
+
+ Add{' '} + + APRS_ENABLED=true + {' '} + to your .env file and restart the server. +
- Optional: Set APRS_FILTER=r/{'{lat}'}/{'{lon}'}/500 to limit to 500km radius around your station. + Optional: Set{' '} + + APRS_FILTER=r/{'{lat}'}/{'{lon}'}/500 + {' '} + to limit to 500km radius around your station.
); @@ -81,18 +83,27 @@ const APRSPanel = ({ return (
{/* Header bar */} -
+
📍 APRS - + {displayStations.length}/{stations.length} @@ -104,9 +115,12 @@ const APRSPanel = ({ style={{ background: showGroupManager ? 'var(--accent-amber)' : 'var(--bg-tertiary)', border: '1px solid var(--border-color)', - borderRadius: '4px', padding: '3px 8px', fontSize: '11px', + borderRadius: '4px', + padding: '3px 8px', + fontSize: '11px', color: showGroupManager ? '#000' : 'var(--text-secondary)', - cursor: 'pointer', fontFamily: 'inherit', + cursor: 'pointer', + fontFamily: 'inherit', }} > 👥 Groups @@ -116,9 +130,12 @@ const APRSPanel = ({ style={{ background: showOnMap ? 'var(--accent-cyan)' : 'var(--bg-tertiary)', border: '1px solid var(--border-color)', - borderRadius: '4px', padding: '3px 8px', fontSize: '11px', + borderRadius: '4px', + padding: '3px 8px', + fontSize: '11px', color: showOnMap ? '#000' : 'var(--text-muted)', - cursor: 'pointer', fontFamily: 'inherit', + cursor: 'pointer', + fontFamily: 'inherit', }} > {showOnMap ? 'ON' : 'OFF'} @@ -127,10 +144,16 @@ const APRSPanel = ({
{/* Group filter tabs */} -
+
{[ { key: 'all', label: `All (${stations.length})` }, ...(allWatchlistCalls.size > 0 ? [{ key: 'watchlist', label: `★ Watchlist` }] : []), @@ -140,11 +163,16 @@ const APRSPanel = ({ key={tab.key} onClick={() => setActiveGroup(tab.key)} style={{ - padding: '3px 8px', fontSize: '10px', borderRadius: '3px', - border: watchlist.activeGroup === tab.key ? '1px solid var(--accent-amber)' : '1px solid var(--border-color)', + padding: '3px 8px', + fontSize: '10px', + borderRadius: '3px', + border: + watchlist.activeGroup === tab.key ? '1px solid var(--accent-amber)' : '1px solid var(--border-color)', background: watchlist.activeGroup === tab.key ? 'var(--accent-amber)' : 'transparent', color: watchlist.activeGroup === tab.key ? '#000' : 'var(--text-muted)', - cursor: 'pointer', fontFamily: 'inherit', fontWeight: watchlist.activeGroup === tab.key ? '600' : '400', + cursor: 'pointer', + fontFamily: 'inherit', + fontWeight: watchlist.activeGroup === tab.key ? '600' : '400', }} > {tab.label} @@ -154,10 +182,13 @@ const APRSPanel = ({ {/* Group manager */} {showGroupManager && ( -
+
Watchlist Groups
@@ -173,17 +204,28 @@ const APRSPanel = ({ onKeyDown={(e) => e.key === 'Enter' && handleAddGroup()} placeholder="New group name..." style={{ - flex: 1, padding: '4px 6px', fontSize: '11px', - background: 'var(--bg-primary)', border: '1px solid var(--border-color)', - borderRadius: '3px', color: 'var(--text-primary)', fontFamily: 'inherit', + flex: 1, + padding: '4px 6px', + fontSize: '11px', + background: 'var(--bg-primary)', + border: '1px solid var(--border-color)', + borderRadius: '3px', + color: 'var(--text-primary)', + fontFamily: 'inherit', }} />
{station.comment && ( -
+
{station.comment}
)}
-
+
{formatAge(station.age)} {station.speed > 0 && {station.speed} kt}
diff --git a/src/components/ActivateFilterManager.jsx b/src/components/ActivateFilterManager.jsx new file mode 100644 index 00000000..fdc1598d --- /dev/null +++ b/src/components/ActivateFilterManager.jsx @@ -0,0 +1,491 @@ +/** + * ActivateFilterManager Component + * Filter modal for ActivatePanel type spots - Bands, Grids, Modes + */ +import React, { useState } from 'react'; + +const BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm']; +const MODES = [ + '-FT8', + 'CONTESTI', + 'CW', + 'CWU', + 'DFCW-90', + 'DOMINO', + 'ECHO', + 'FREEDV', + 'FSK441', + 'FSQ', + 'FST4', + 'FST4W', + 'FST4W-90', + 'FT4', + 'FT8', + 'HELL', + 'HFDL', + 'JT', + 'JT4', + 'JT65', + 'JT65B', + 'JT9', + 'JTMS', + 'JS8', + 'LZ3CB', + 'MFSK16', + 'MFSK22', + 'MFSK32', + 'MSK144', + 'NULL', + 'OLIVIA', + 'OLIVIA 1', + 'OLIVIA 3', + 'OLIVIA 4', + 'OLIVIA 8', + 'OPERA', + 'PI4', + 'PKT', + 'POCSAG', + 'PSK', + 'PSK31', + 'PSK32', + 'PSK63', + 'Q65', + 'Q65-30A', + 'Q65A', + 'Q65B', + 'Q65D', + 'ROS', + 'RTTY', + 'RTTY 45', + 'SIM31', + 'SIM63', + 'SSB', + 'SSTV', + 'THOR-M', + 'THOR11', + 'THOR22', + 'THOR32', + 'THRB', + 'VARAC', + 'WSPR', +]; + +// Common grid field prefixes by region +const GRID_REGIONS = [ + { name: 'North America East', grids: ['FN', 'FM', 'EN', 'EM', 'DN', 'DM'] }, + { name: 'North America West', grids: ['CN', 'CM', 'DM', 'DN', 'BN', 'BM'] }, + { name: 'Europe', grids: ['JO', 'JN', 'IO', 'IN', 'KO', 'KN', 'LO', 'LN'] }, + { name: 'South America', grids: ['GG', 'GH', 'GI', 'FG', 'FH', 'FI', 'FF', 'FE'] }, + { name: 'Asia', grids: ['PM', 'PL', 'OM', 'OL', 'QL', 'QM', 'NM', 'NL'] }, + { name: 'Oceania', grids: ['QF', 'QG', 'PF', 'PG', 'RF', 'RG', 'OF', 'OG'] }, + { name: 'Africa', grids: ['KH', 'KG', 'JH', 'JG', 'IH', 'IG'] }, +]; + +export const ActivateFilterManager = ({ filters, onFilterChange, isOpen, onClose, name }) => { + const [activeTab, setActiveTab] = useState('bands'); + const [customGrid, setCustomGrid] = useState(''); + + if (!isOpen) return null; + + const toggleArrayItem = (key, item) => { + const current = filters[key] || []; + const newArray = current.includes(item) ? current.filter((x) => x !== item) : [...current, item]; + onFilterChange({ ...filters, [key]: newArray.length ? newArray : undefined }); + }; + + const selectAll = (key, items) => { + onFilterChange({ ...filters, [key]: [...items] }); + }; + + const clearFilter = (key) => { + const newFilters = { ...filters }; + delete newFilters[key]; + onFilterChange(newFilters); + }; + + const clearAllFilters = () => { + onFilterChange({}); + }; + + const addCustomGrid = () => { + if (customGrid.trim() && customGrid.length >= 2) { + const grid = customGrid.toUpperCase().substring(0, 2); + const current = filters?.grids || []; + if (!current.includes(grid)) { + onFilterChange({ ...filters, grids: [...current, grid] }); + } + setCustomGrid(''); + } + }; + + const getActiveFilterCount = () => { + let count = 0; + if (filters?.bands?.length) count += filters.bands.length; + if (filters?.grids?.length) count += filters.grids.length; + if (filters?.modes?.length) count += filters.modes.length; + if (filters?.direction && filters.direction !== 'both') count += 1; + return count; + }; + + const tabStyle = (active) => ({ + padding: '8px 16px', + background: active ? 'var(--accent-amber)' : 'transparent', + border: 'none', + borderBottom: active ? '2px solid var(--accent-amber)' : '2px solid transparent', + color: active ? '#000' : 'var(--text-muted)', + fontSize: '13px', + cursor: 'pointer', + fontFamily: 'inherit', + fontWeight: active ? '600' : '400', + }); + + const chipStyle = (selected) => ({ + padding: '6px 12px', + background: selected ? 'var(--accent-amber)' : 'var(--bg-tertiary)', + border: `1px solid ${selected ? 'var(--accent-amber)' : 'var(--border-color)'}`, + borderRadius: '4px', + color: selected ? '#000' : 'var(--text-secondary)', + fontSize: '12px', + cursor: 'pointer', + fontFamily: 'JetBrains Mono, monospace', + fontWeight: selected ? '600' : '400', + }); + + const renderBandsTab = () => ( +
+
+ Filter by Band +
+ + +
+
+
+ {BANDS.map((band) => ( + + ))} +
+
+ {filters?.bands?.length ? `Showing only: ${filters.bands.join(', ')}` : 'Showing all bands (no filter)'} +
+
+ ); + + const renderGridsTab = () => ( +
+
+ Filter by Grid Square + +
+ + {/* Custom grid input */} +
+ setCustomGrid(e.target.value.toUpperCase())} + maxLength={2} + onKeyPress={(e) => e.key === 'Enter' && addCustomGrid()} + style={{ + flex: 1, + padding: '8px 12px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '4px', + color: 'var(--text-primary)', + fontSize: '13px', + fontFamily: 'JetBrains Mono', + }} + /> + +
+ + {/* Selected grids */} + {filters?.grids?.length > 0 && ( +
+
Active Grid Filters:
+
+ {filters.grids.map((grid) => ( + + ))} +
+
+ )} + + {/* Quick select by region */} +
Quick Select by Region:
+ {GRID_REGIONS.map((region) => ( +
+
{region.name}
+
+ {region.grids.map((grid) => ( + + ))} +
+
+ ))} +
+ ); + + const renderModesTab = () => ( +
+
+ Filter by Mode +
+ + +
+
+
+ {MODES.map((mode) => ( + + ))} +
+
+ {filters?.modes?.length ? `Showing only: ${filters.modes.join(', ')}` : 'Showing all modes (no filter)'} +
+
+ ); + + return ( +
e.target === e.currentTarget && onClose()} + > +
+ {/* Header */} +
+
+

⌇ {name} Filters

+ + {getActiveFilterCount()} filter{getActiveFilterCount() !== 1 ? 's' : ''} active + +
+ +
+ + {/* Tabs */} +
+ + + +
+ + {/* Tab Content */} +
+ {activeTab === 'bands' && renderBandsTab()} + {activeTab === 'grids' && renderGridsTab()} + {activeTab === 'modes' && renderModesTab()} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; + +export default ActivateFilterManager; diff --git a/src/components/ActivatePanel.jsx b/src/components/ActivatePanel.jsx index b35e308c..df622745 100644 --- a/src/components/ActivatePanel.jsx +++ b/src/components/ActivatePanel.jsx @@ -4,6 +4,7 @@ */ import React from 'react'; import CallsignLink from './CallsignLink.jsx'; +import { IconSearch, IconRefresh, IconMap, IconTag } from './Icons.jsx'; export const ActivatePanel = ({ name, @@ -19,10 +20,20 @@ export const ActivatePanel = ({ onToggleLabelsOnMap, onSpotClick, onHoverSpot, + filters, + onOpenFilters, + filteredData, }) => { const staleMinutes = lastUpdated ? Math.floor((Date.now() - lastUpdated) / 60000) : null; const isStale = staleMinutes !== null && staleMinutes >= 5; const checkedTime = lastChecked ? new Date(lastChecked).toISOString().substr(11, 5) + 'z' : ''; + const filterActiveColor = '#ffaa00'; + const spots = filteredData ? filteredData : data; + + let filterCount = 0; + if (filters?.bands?.length) filterCount += filters.bands.length; + if (filters?.grids?.length) filterCount += filters.grids.length; + if (filters?.modes?.length) filterCount += filters.modes.length; return (
@@ -61,24 +72,27 @@ export const ActivatePanel = ({ )} +
- + {typeof onOpenFilters === 'function' && ( + + )} {typeof onToggleLabelsOnMap === 'function' && ( )} + +
@@ -106,9 +138,9 @@ export const ActivatePanel = ({
- ) : data && data.length > 0 ? ( + ) : spots && spots.length > 0 ? (
- {data.map((spot, i) => ( + {spots.map((spot, i) => (
{ return ( ); }; diff --git a/src/components/PSKFilterManager.jsx b/src/components/PSKFilterManager.jsx index 6ecec5d4..db089b37 100644 --- a/src/components/PSKFilterManager.jsx +++ b/src/components/PSKFilterManager.jsx @@ -138,7 +138,9 @@ export const PSKFilterManager = ({ filters, onFilterChange, isOpen, onClose }) = return (
- Map Direction Filter + + Map Direction Filter +
Useful when WSJT-X is also enabled — avoids duplicate plots for the same stations.
@@ -161,14 +163,30 @@ export const PSKFilterManager = ({ filters, onFilterChange, isOpen, onClose }) = textAlign: 'left', }} > - - {direction === opt.value ? '● ' : '○ '}{opt.label} + + {direction === opt.value ? '● ' : '○ '} + {opt.label} {opt.desc} ))}
-
+
Map Legend:
━━ TX (solid line, ● circle) diff --git a/src/components/PotaSotaPanel.jsx b/src/components/PotaSotaPanel.jsx index 8411a077..cc399866 100644 --- a/src/components/PotaSotaPanel.jsx +++ b/src/components/PotaSotaPanel.jsx @@ -18,28 +18,51 @@ export const PotaSotaPanel = ({ potaLastChecked, showPOTA, onTogglePOTA, + showPOTALabels, + togglePOTALabels, + onPOTASpotClick, + potaFilters, + setShowPotaFilters, + filteredPotaSpots, + wwffData, wwffLoading, wwffLastUpdated, wwffLastChecked, showWWFF, onToggleWWFF, + showWWFFLabels, + toggleWWFFLabels, + onWWFFSpotClick, + wwffFilters, + setShowWwffFilters, + filteredWwffSpots, + sotaData, sotaLoading, sotaLastUpdated, sotaLastChecked, showSOTA, onToggleSOTA, + showSOTALabels, + toggleSOTALabels, + onSOTASpotClick, + sotaFilters, + setShowSotaFilters, + filteredSotaSpots, + wwbotaData, wwbotaLoading, wwbotaLastUpdated, wwbotaConnected, showWWBOTA, onToggleWWBOTA, - onPOTASpotClick, - onWWFFSpotClick, - onSOTASpotClick, + showWWBOTALabels, + toggleWWBOTALabels, onWWBOTASpotClick, + wwbotaFilters, + setShowWwbotaFilters, + filteredWwbotaSpots, }) => { const [activeTab, setActiveTab] = useState(() => { try { @@ -127,6 +150,11 @@ export const PotaSotaPanel = ({ showOnMap={showPOTA} onToggleMap={onTogglePOTA} onSpotClick={onPOTASpotClick} + showLabelsOnMap={showPOTALabels} + onToggleLabelsOnMap={togglePOTALabels} + filters={potaFilters} + onOpenFilters={setShowPotaFilters} + filteredData={filteredPotaSpots} /> ) : activeTab === 'sota' ? ( ) : activeTab === 'wwbota' ? ( ) : ( )}
diff --git a/src/components/SOTAPanel.jsx b/src/components/SOTAPanel.jsx index 48a94dbf..39e8dec0 100644 --- a/src/components/SOTAPanel.jsx +++ b/src/components/SOTAPanel.jsx @@ -15,6 +15,9 @@ export const SOTAPanel = ({ onToggleLabelsOnMap = true, onSpotClick, onHoverSpot, + filters, + onOpenFilters, + filteredData, }) => { return ( ); }; diff --git a/src/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx index 1a046b74..a0b73b9d 100644 --- a/src/components/SettingsPanel.jsx +++ b/src/components/SettingsPanel.jsx @@ -2357,7 +2357,14 @@ export const SettingsPanel = ({ marginBottom: '12px', }} > -
+