diff --git a/server/datahandler.js b/server/datahandler.js index 864833f..d0e2291 100644 --- a/server/datahandler.js +++ b/server/datahandler.js @@ -420,7 +420,8 @@ function handleData(wss, receivedData, rdsWss) { azi: currentTx.azimuth, id: currentTx.id, pi: currentTx.pi, - reg: currentTx.reg + reg: currentTx.reg, + otherMatches: currentTx.others }; } }) diff --git a/server/tx_search.js b/server/tx_search.js index 53d658b..2380cce 100644 --- a/server/tx_search.js +++ b/server/tx_search.js @@ -2,7 +2,7 @@ const fetch = require('node-fetch'); const { serverConfig } = require('./server_config'); const consoleCmd = require('./console'); -let cachedData = {}; +let localDb = {}; let lastFetchTime = 0; const fetchInterval = 1000; const esSwitchCache = {"lastCheck":0, "esSwitch":false}; @@ -18,7 +18,8 @@ let weightedErp = 10; let weightedDist = 400; const algorithms = [ [10, 400], - [30, 500] + [30, 500], + [5, 400] ]; const algoSetting = parseInt(serverConfig.webserver.txIdAlgorithm); @@ -27,6 +28,19 @@ if (typeof algorithms[algoSetting] !== 'undefined') { weightedDist = algorithms[algoSetting][1]; } +// IIFE to build the local TX DB cache from the endpoint. +(async () => { + try { + consoleCmd.logInfo('Fetching transmitter database...'); + const response = await fetch(`https://maps.fmdx.org/api?qth=${serverConfig.identification.lat},${serverConfig.identification.lon}`); + if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`); + localDb = await response.json(); + consoleCmd.logInfo('Transmitter database successfully loaded.'); + } catch (error) { + consoleCmd.logError("Failed to fetch transmitter database:", error); + } +})(); + // Load the US states GeoJSON data async function loadUsStatesGeoJson() { if (!usStatesGeoJson) { @@ -82,8 +96,63 @@ function getStateForCoordinates(lat, lon) { return null; } +/** + * Compares the standardized rdsPs string with the station's PS value. + * The rdsPs string is standardized by replacing spaces with underscores and converting to lowercase. + * The station's PS value is split into tokens (e.g., "__mdr___ _kultur_" -> ["__mdr___", "_kultur_"]). + * The function iterates through all tokens and checks if any token yields at least three valid (non "_" ) matches. + * Only positions where rdsPs is not an underscore are compared. + * If at least three valid matches are found for any token, the function returns true. + */ +function validPsCompare(rdsPs, stationPs) { + // Standardize the rdsPs string: replace spaces with underscores and convert to lowercase. + const standardizedRdsPs = rdsPs.replace(/ /g, '_').toLowerCase(); + + // Split stationPs into tokens (e.g., "__mdr___ _kultur_" -> ["__mdr___", "_kultur_"]) + const psTokens = stationPs.split(/\s+/).filter(token => token.length > 0).map(token => token.toLowerCase()); + + // Iterate through all tokens and check if any token yields at least three valid (non "_" ) matches. + for (let token of psTokens) { + // If the token's length does not match the standardized rdsPs length, skip this token. + if (token.length !== standardizedRdsPs.length) continue; + + let matchCount = 0; + for (let i = 0; i < standardizedRdsPs.length; i++) { + // Skip this position if the character in standardizedRdsPs is an underscore. + if (standardizedRdsPs[i] === '_') continue; + if (token[i] === standardizedRdsPs[i]) { + matchCount++; + } + } + if (matchCount >= 3) { + return true; + } + } + return false; +} + +function evaluateStation(station) { + let esMode = checkEs(); + let weightDistance = station.distanceKm; + if (esMode && station.distanceKm > 500) { + weightDistance = Math.abs(station.distanceKm - 1500); + } + let erp = station.erp && station.erp > 0 ? station.erp : 1; + let extraWeight = erp > 30 && station.distanceKm <= 500 ? 0.3 : 0; + let score = 0; + // If ERP is 1W, use a simpler formula to avoid zero-scoring. + if (erp === 0.001) { + score = erp / station.distanceKm; + } else { + score = ((10 * (Math.log10(erp * 1000))) / weightDistance) + extraWeight; + } + return score; +} + // Fetch data from maps async function fetchTx(freq, piCode, rdsPs) { + let match = null; + let multiMatches = []; const now = Date.now(); freq = parseFloat(freq); @@ -91,146 +160,74 @@ async function fetchTx(freq, piCode, rdsPs) { if (now - lastFetchTime < fetchInterval || serverConfig.identification.lat.length < 2 || freq < 87 + || Object.keys(localDb).length === 0 || (currentPiCode == piCode && currentRdsPs == rdsPs)) { return Promise.resolve(); } lastFetchTime = now; + if (serverConfig.webserver.rdsMode === true) await loadUsStatesGeoJson(); - if (cachedData[freq]) { - return processData(cachedData[freq], piCode, rdsPs); + const filteredLocations = Object.values(localDb.locations) + .map(locData => ({ + ...locData, + stations: locData.stations.filter(station => + station.freq === freq && + (station.pi === piCode.toUpperCase() || station.pireg === piCode.toUpperCase() ) && + validPsCompare(rdsPs, station.ps) + ) + })) + .filter(locData => locData.stations.length > 0); // Ensure locations with at least one matching station remain + + for (loc of filteredLocations) { + loc = Object.assign(loc, loc.stations[0]); + delete loc.stations; + const dist = haversine(serverConfig.identification.lat, serverConfig.identification.lon, loc.lat, loc.lon); + loc = Object.assign(loc, dist); + loc.detectedByPireg = (loc.pireg === piCode.toUpperCase()); + } + + if (filteredLocations.length > 1) { + for (loc of filteredLocations) { + loc.score = evaluateStation(loc); + } + match = filteredLocations.reduce((max, obj) => obj.score > max.score ? obj : max, filteredLocations[0]); + multiMatches = filteredLocations.filter(obj => obj !== match); + } else if (filteredLocations.length === 1) { + match = filteredLocations[0]; } - const url = "https://maps.fmdx.org/api/?freq=" + freq; - - try { - // Try POST first - const postResponse = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ freq }), // You can omit or customize this - redirect: 'manual' - }); - - if (!postResponse.ok) throw new Error(`POST failed: ${postResponse.status}`); - const data = await postResponse.json(); - cachedData[freq] = data; - if (serverConfig.webserver.rdsMode === true) await loadUsStatesGeoJson(); - return processData(data, piCode, rdsPs); - } catch (postError) { - console.warn("POST failed, trying GET:", postError); - - try { - const getResponse = await fetch(url, { redirect: 'manual' }); - if (!getResponse.ok) throw new Error(`GET failed: ${getResponse.status}`); - const data = await getResponse.json(); - cachedData[freq] = data; - if (serverConfig.webserver.rdsMode === true) await loadUsStatesGeoJson(); - return processData(data, piCode, rdsPs); - } catch (getError) { - console.error("GET also failed:", getError); - return null; - } - } -} - -async function processData(data, piCode, rdsPs) { - let matchingStation = null; - let matchingCity = null; - let maxScore = -Infinity; - let txAzimuth; - let maxDistance; - let esMode = checkEs(); - let detectedByPireg = false; - currentPiCode = piCode; - currentRdsPs = rdsPs; - - function evaluateStation(station, city, distance) { - let weightDistance = distance.distanceKm; - if (esMode && distance.distanceKm > 500) { - weightDistance = Math.abs(distance.distanceKm - 1500); - } - let erp = station.erp && station.erp > 0 ? station.erp : 1; - let extraWeight = erp >= weightedErp && distance.distanceKm <= weightedDist ? 0.3 : 0; - let score = 0; - // If ERP is 1W, use a simpler formula to avoid zero-scoring. - if (erp === 0.001) { - score = erp / distance.distanceKm; - } else { - score = ((10 * Math.log10(erp * 1000)) / weightDistance) + extraWeight; - } - if (score > maxScore) { - maxScore = score; - txAzimuth = distance.azimuth; - matchingStation = station; - matchingCity = city; - maxDistance = distance.distanceKm; - } - } - - // First attempt: Try to match station using the piCode - for (const cityId in data.locations) { - const city = data.locations[cityId]; - if (city.stations) { - for (const station of city.stations) { - if (station.pi === piCode.toUpperCase() && !station.extra && station.ps && station.ps.toLowerCase().includes(rdsPs.replace(/ /g, '_').replace(/^_*(.*?)_*$/, '$1').toLowerCase())) { - const distance = haversine(serverConfig.identification.lat, serverConfig.identification.lon, city.lat, city.lon); - evaluateStation(station, city, distance); - detectedByPireg = false; - } + if (match) { + if (match.itu === 'USA') { + const state = getStateForCoordinates(match.lat, match.lon); + if (state) { + match.state = state; // Add state to matchingCity } } - } - - // Fallback to pireg if no match is found - if (!matchingStation) { - for (const cityId in data.locations) { - const city = data.locations[cityId]; - if (city.stations) { - for (const station of city.stations) { - if (station.pireg && station.pireg.toUpperCase() === piCode.toUpperCase() && !station.extra && station.ps && station.ps.toLowerCase().includes(rdsPs.replace(/ /g, '_').replace(/^_*(.*?)_*$/, '$1').toLowerCase())) { - const distance = haversine(serverConfig.identification.lat, serverConfig.identification.lon, city.lat, city.lon); - evaluateStation(station, city, distance); - detectedByPireg = true; - } - } - } - } - } - - // Determine the state if the city is in the USA - if (matchingStation && matchingCity.itu === 'USA') { - const state = getStateForCoordinates(matchingCity.lat, matchingCity.lon); - if (state) { - matchingCity.state = state; // Add state to matchingCity - } - } - - if (matchingStation) { return { - station: detectedByPireg - ? `${matchingStation.station.replace("R.", "Radio ")}${matchingStation.regname ? ' ' + matchingStation.regname : ''}` - : matchingStation.station.replace("R.", "Radio "), - pol: matchingStation.pol.toUpperCase(), - erp: matchingStation.erp && matchingStation.erp > 0 ? matchingStation.erp : '?', - city: matchingCity.name, - itu: matchingCity.state ? matchingCity.state + ', ' + matchingCity.itu : matchingCity.itu, - distance: maxDistance.toFixed(0), - azimuth: txAzimuth.toFixed(0), - id: matchingStation.id, - pi: matchingStation.pi, + station: match.detectedByPireg + ? `${match.station.replace("R.", "Radio ")}${match.regname ? ' ' + match.regname : ''}` + : match.station.replace("R.", "Radio "), + pol: match.pol.toUpperCase(), + erp: match.erp && match.erp > 0 ? match.erp : '?', + city: match.name, + itu: match.state ? match.state + ', ' + match.itu : match.itu, + distance: match.distanceKm.toFixed(0), + azimuth: match.azimuth.toFixed(0), + id: match.id, + pi: match.pi, foundStation: true, - reg: detectedByPireg + reg: match.detectedByPireg, + others: multiMatches, }; } else { - return; + return Promise.resolve(); } } function checkEs() { const now = Date.now(); const url = "https://fmdx.org/includes/tools/get_muf.php"; - let esSwitch = false; if (now - esSwitchCache.lastCheck < esFetchInterval) { return esSwitchCache.esSwitch; diff --git a/web/css/breadcrumbs.css b/web/css/breadcrumbs.css index d417698..79ba36d 100644 --- a/web/css/breadcrumbs.css +++ b/web/css/breadcrumbs.css @@ -119,6 +119,14 @@ label { font-size: 20px; } +#data-station-others span { + color: var(--color-main); + background: var(--color-4); + margin-left: 4px; + padding: 0 5px; + border-radius: 3px; +} + .highest-signal-container { margin-bottom: -20px !important; } diff --git a/web/index.ejs b/web/index.ejs index df37edf..5cce173 100644 --- a/web/index.ejs +++ b/web/index.ejs @@ -287,7 +287,7 @@ [] - kW [] + kW [] @@ -406,6 +406,17 @@ + +
diff --git a/web/js/main.js b/web/js/main.js index 5a121ac..bb13b6e 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -905,6 +905,33 @@ function throttle(fn, wait) { return wrapper; } +function buildAltTxList(txList) { + const wrapper = '
'; + let outString = ''; + outString += wrapper; + for (let i = 0; i < txList.length; i++) { + const tx = txList[i]; + outString += `
+
+

+ ${tx.station.replace("R.", "Radio ").replace(/%/g, '%25')} +

+

+ ${tx.name} [G] +

+ + ${tx.erp} kW [${tx.pol.toUpperCase()}] ${tx.distanceKm.toFixed(0)} km ${tx.azimuth.toFixed(0)}° + +
+
`; + if (i % 2 !== 0) { + outString += `
${wrapper}`; + } + } + outString += '
'; + return outString; +} + function updateTextIfChanged($element, newText) { if ($element.text() !== newText) { $element.text(newText); @@ -976,7 +1003,10 @@ const updateDataElements = throttle(function(parsedData) { updateTextIfChanged($('#data-station-itu'), parsedData.txInfo.itu); updateTextIfChanged($('#data-station-pol'), parsedData.txInfo.pol); updateHtmlIfChanged($('#data-station-azimuth'), parsedData.txInfo.azi + '°'); + updateHtmlIfChanged($('#data-station-others'), parsedData.txInfo.otherMatches.length > 0 ? ('+' + parsedData.txInfo.otherMatches.length +'') : ''); const txDistance = localStorage.getItem('imperialUnits') == "true" ? (Number(parsedData.txInfo.dist) * 0.621371192).toFixed(0) + " mi" : parsedData.txInfo.dist + " km"; + const altTxInfo = buildAltTxList(parsedData.txInfo.otherMatches); + updateHtmlIfChanged($('#alternative-txes'), altTxInfo); updateTextIfChanged($('#data-station-distance'), txDistance); $dataStationContainer.css('display', 'block'); } else { diff --git a/web/js/modal.js b/web/js/modal.js index 6fee1e3..09415d1 100644 --- a/web/js/modal.js +++ b/web/js/modal.js @@ -45,7 +45,12 @@ $(document).ready(function() { $(".tuner-mobile-settings").on("click", function () { $(".popup-window").fadeOut(200); $("#popup-panel-mobile-settings").fadeIn(200); -}); + }); + + $("#data-station-others").on("click", function () { + $(".popup-window").fadeOut(200); + $("#popup-panel-transmitters").fadeIn(200); + }); }); function initPopups() { diff --git a/web/setup.ejs b/web/setup.ejs index 96242cd..7d1b68e 100644 --- a/web/setup.ejs +++ b/web/setup.ejs @@ -337,6 +337,7 @@ options: [ { value: '0', label: 'Algorithm 1' }, { value: '1', label: 'Algorithm 2' }, + { value: '2', label: 'Algorithm 3' }, ] }) %>