[QSO Map] Tweaks, features, refactor

This commit is contained in:
Andreas Kristiansen
2025-12-28 14:31:23 +01:00
parent 6d772373ad
commit a2e637414a
4 changed files with 609 additions and 408 deletions

View File

@@ -24,30 +24,35 @@ class Map extends CI_Controller {
public function qso_map() { public function qso_map() {
$this->load->library('Geojson'); $this->load->library('Geojson');
$this->load->model('Map_model'); $this->load->model('Map_model');
$this->load->model('stations');
// Get supported DXCC countries with state data // Get supported DXCC countries with state data
$supported_dxccs = $this->geojson->getSupportedDxccs(); $data['supported_dxccs'] = $this->geojson->getSupportedDxccs();
$supported_country_codes = array_keys($data['supported_dxccs']);
// Fetch available countries from the logbook // Fetch available countries from the logbook
$countries = $this->Map_model->get_available_countries(); $data['countries'] = $this->Map_model->get_available_countries($supported_country_codes);
// Filter countries to only include those with GeoJSON support
$supported_country_codes = array_keys($supported_dxccs);
$filtered_countries = array_filter($countries, function($country) use ($supported_country_codes) {
return in_array($country['COL_DXCC'], $supported_country_codes);
});
// Fetch station profiles // Fetch station profiles
$station_profiles = $this->Map_model->get_station_profiles(); $data['station_profiles'] = $this->stations->all_of_user()->result();
$data['homegrid'] = explode(',', $this->stations->find_gridsquare());
$data['countries'] = $filtered_countries;
$data['station_profiles'] = $station_profiles;
$data['supported_dxccs'] = $supported_dxccs;
$data['page_title'] = __("QSO Map"); $data['page_title'] = __("QSO Map");
$footerData = [];
$footerData['scripts'] = [
'assets/js/leaflet/geocoding.js',
'assets/js/leaflet/L.Maidenhead.js',
'assets/js/sections/qso_map.js?' . filemtime(realpath(__DIR__ . "/../../assets/js/sections/qso_map.js")),
'assets/js/sections/itumap_geojson.js?' . filemtime(realpath(__DIR__ . "/../../assets/js/sections/itumap_geojson.js")),
'assets/js/sections/cqmap_geojson.js?' . filemtime(realpath(__DIR__ . "/../../assets/js/sections/cqmap_geojson.js")),
];
$this->load->view('interface_assets/header', $data); $this->load->view('interface_assets/header', $data);
$this->load->view('map/qso_map', $data); $this->load->view('map/qso_map');
$this->load->view('interface_assets/footer'); $this->load->view('interface_assets/footer', $footerData);
} }
/** /**

View File

@@ -5,32 +5,18 @@ class Map_model extends CI_Model {
/** /**
* Get available countries from the logbook with QSOs * Get available countries from the logbook with QSOs
*/ */
public function get_available_countries() { public function get_available_countries($supported_country_codes) {
$this->db->select('DISTINCT dxcc_entities.name AS COL_COUNTRY, COL_DXCC, COUNT(*) as qso_count', FALSE); $sql = "select DISTINCT dxcc_entities.name AS dxcc_name, dxcc_entities.prefix, COL_DXCC, COUNT(*) as qso_count
$this->db->from($this->config->item('table_name')); from " . $this->config->item('table_name') . " thcv
$this->db->join('station_profile', 'station_profile.station_id = ' . $this->config->item('table_name') . '.station_id'); join station_profile ON station_profile.station_id = thcv.station_id
$this->db->join('dxcc_entities', 'dxcc_entities.adif = ' . $this->config->item('table_name') . '.COL_DXCC'); join dxcc_entities ON dxcc_entities.adif = thcv.COL_DXCC
$this->db->where('station_profile.user_id', $this->session->userdata('user_id')); where station_profile.user_id = ?
$this->db->where("LENGTH(COL_GRIDSQUARE) >=", 6); // At least 6 chars and thcv.COL_DXCC IN (" . implode(',', array_fill(0, count($supported_country_codes), '?')) . ")
$this->db->group_by('COL_COUNTRY, COL_DXCC'); and LENGTH(thcv.COL_GRIDSQUARE) >= 6
$this->db->order_by('COL_COUNTRY'); group by dxcc_name, thcv.COL_DXCC, dxcc_entities.prefix
order by prefix ASC";
$query = $this->db->get(); $query = $this->db->query($sql, array_merge([$this->session->userdata('user_id')], $supported_country_codes));
return $query->result_array();
}
/**
* Get available station profiles for the user
*/
public function get_station_profiles() {
$this->db->select('station_profile.station_id, station_profile.station_profile_name', FALSE);
$this->db->from($this->config->item('table_name'));
$this->db->join('station_profile', 'station_profile.station_id = ' . $this->config->item('table_name') . '.station_id');
$this->db->where('station_profile.user_id', $this->session->userdata('user_id'));
$this->db->group_by('station_profile.station_id, station_profile.station_profile_name');
$this->db->order_by('station_profile.station_profile_name');
$query = $this->db->get();
return $query->result_array(); return $query->result_array();
} }
@@ -45,21 +31,25 @@ class Map_model extends CI_Model {
$this->load->library('DxccFlag'); $this->load->library('DxccFlag');
} }
$this->db->select('COL_PRIMARY_KEY, COL_CALL, COL_GRIDSQUARE, COL_COUNTRY, COL_DXCC, COL_MODE, COL_BAND, COL_TIME_ON, COL_RST_SENT, COL_RST_RCVD, station_profile.station_profile_name', FALSE); $sql = "select COL_PRIMARY_KEY, COL_CALL, COL_GRIDSQUARE, COL_COUNTRY, COL_DXCC, COL_MODE, COL_BAND, COL_TIME_ON, COL_RST_SENT, COL_RST_RCVD, station_profile.station_profile_name
$this->db->from($this->config->item('table_name')); from " . $this->config->item('table_name') . "
$this->db->join('station_profile', 'station_profile.station_id = ' . $this->config->item('table_name') . '.station_id'); join station_profile ON station_profile.station_id = " . $this->config->item('table_name') . ".station_id
$this->db->where('station_profile.user_id', $this->session->userdata('user_id')); where station_profile.user_id = ?
$this->db->where('COL_COUNTRY', $country); and COL_COUNTRY = ?";
$bindings[] = $this->session->userdata('user_id');
$bindings[] = $country;
// Add station filter if specified // Add station filter if specified
if ($station_id !== null && $station_id !== '') { if ($station_id !== null && $station_id !== '') {
$this->db->where('station_profile.station_id', $station_id); $sql .= " and station_profile.station_id = ?";
$bindings[] = $station_id;
} }
$this->db->where("LENGTH(COL_GRIDSQUARE) >=", 6); // At least 6 chars
$this->db->order_by('COL_TIME_ON', 'DESC'); $sql .= "and LENGTH(COL_GRIDSQUARE) >= 6
order by COL_TIME_ON DESC";
$query = $this->db->get(); $query = $this->db->query($sql, $bindings);
$qsos = $query->result_array(); $qsos = $query->result_array();
// Process QSOs and convert gridsquares to coordinates // Process QSOs and convert gridsquares to coordinates

View File

@@ -1,3 +1,12 @@
<script>
// Pass supported DXCC list from PHP to JavaScript
const supportedDxccs = <?php echo json_encode(array_keys($supported_dxccs)); ?>;
const homegrid = "<?php echo strtoupper($homegrid[0]); ?>";
let lang_gen_hamradio_cq_zones = '<?= _pgettext("Map Options", "CQ Zones"); ?>';
let lang_gen_hamradio_itu_zones = '<?= _pgettext("Map Options", "ITU Zones"); ?>';
let lang_gen_hamradio_gridsquares = '<?= _pgettext("Map Options", "Gridsquares"); ?>';
</script>
<div class="container"> <div class="container">
<h2><?= ('GeoJSON QSO Map'); ?></h2> <h2><?= ('GeoJSON QSO Map'); ?></h2>
@@ -7,9 +16,9 @@
<select class="form-select" id="countrySelect" style="min-width: 200px;"> <select class="form-select" id="countrySelect" style="min-width: 200px;">
<option value=""><?= __("Choose a country...") ?></option> <option value=""><?= __("Choose a country...") ?></option>
<?php foreach ($countries as $country): ?> <?php foreach ($countries as $country): ?>
<option value="<?php echo htmlspecialchars(ucwords(strtolower(($country['COL_COUNTRY'])), "- (/")); ?>" <option value="<?php echo htmlspecialchars(ucwords(strtolower(($country['dxcc_name'])), "- (/")); ?>"
data-dxcc="<?php echo htmlspecialchars($country['COL_DXCC']); ?>"> data-dxcc="<?php echo htmlspecialchars($country['COL_DXCC']); ?>">
<?php echo htmlspecialchars(ucwords(strtolower(($country['COL_COUNTRY'])), "- (/") . ' (' . $country['qso_count'] . ' ' . __("QSOs") . ')'); ?> <?php echo htmlspecialchars($country['prefix']) . ' - ' . htmlspecialchars(ucwords(strtolower(($country['dxcc_name'])), "- (/") . ' (' . $country['qso_count'] . ' ' . __("QSOs") . ')'); ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
@@ -19,8 +28,8 @@
<select class="form-select" id="locationSelect" style="min-width: 200px;"> <select class="form-select" id="locationSelect" style="min-width: 200px;">
<option value="all">All</option> <option value="all">All</option>
<?php foreach ($station_profiles as $profile): ?> <?php foreach ($station_profiles as $profile): ?>
<option value="<?php echo htmlspecialchars($profile['station_id']); ?>"> <option value="<?php echo htmlspecialchars($profile->station_id); ?>">
<?php echo htmlspecialchars($profile['station_profile_name']); ?> <?php echo htmlspecialchars($profile->station_profile_name); ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
@@ -46,14 +55,32 @@
<div id="mapContainer" class="mt-3" style="display: none;"> <div id="mapContainer" class="mt-3" style="display: none;">
<div id="mapgeojson" style="border: 1px solid #ccc;"></div> <div id="mapgeojson" style="border: 1px solid #ccc;"></div>
<div class="mt-2"> <div class="coordinates d-flex">
<small class="text-muted"> <div class="cohidden"><?= __("Latitude") ?>:&nbsp;</div>
<i class="fas fa-info-circle"></i> <div class="cohidden col-auto text-success fw-bold" id="latDeg"></div>
<?= ('Map shows QSOs with 6+ character gridsquares.') ?> <div class="cohidden"><?= __("Longitude") ?>:&nbsp;</div>
</small> <div class="cohidden col-auto text-success fw-bold" id="lngDeg"></div>
</div> <div class="cohidden"><?= __("Gridsquare") ?>:&nbsp;</div>
<div class="cohidden col-auto text-success fw-bold" id="locator"></div>
<div class="cohidden"><?= __("Distance") ?>:&nbsp;</div>
<div class="cohidden col-auto text-success fw-bold" id="distance"></div>
<div class="cohidden"><?= __("Bearing") ?>:&nbsp;</div>
<div class="cohidden col-auto text-success fw-bold" id="bearing"></div>
<div class="cohidden"><?= __("CQ Zone") ?>:&nbsp;</div>
<div class="cohidden col-auto text-success fw-bold" id="cqzonedisplay"></div>
<div class="cohidden"><?= __("ITU Zone") ?>:&nbsp;</div>
<div class="cohidden col-auto text-success fw-bold" id="ituzonedisplay"></div>
</div>
<div class="mt-2">
<small class="text-muted">
<i class="fas fa-info-circle"></i>
<?= ('Map shows QSOs with 6+ character gridsquares.') ?>
</small>
</div>
</div> </div>
<div id="noDataMessage" class="alert alert-warning mt-3" style="display: none;"> <div id="noDataMessage" class="alert alert-warning mt-3" style="display: none;">
<i class="fas fa-exclamation-triangle"></i> <i class="fas fa-exclamation-triangle"></i>
<?= ('No QSOs with 6+ character grids found for the selected country.') ?> <?= ('No QSOs with 6+ character grids found for the selected country.') ?>
@@ -63,9 +90,9 @@
<style> <style>
#mapgeojson { #mapgeojson {
border-radius: 4px; border-radius: 4px;
height: 1000px !important; height: calc(100vh - 250px);
width: 100% !important; width: 100% !important;
min-height: 600px; min-height: 400px;
} }
.leaflet-popup-content { .leaflet-popup-content {
min-width: 200px; min-width: 200px;
@@ -79,10 +106,6 @@
border-radius: 50%; border-radius: 50%;
box-shadow: 0 2px 5px rgba(0,0,0,0.3); box-shadow: 0 2px 5px rgba(0,0,0,0.3);
} }
.leaflet-container {
height: 600px !important;
width: 100% !important;
}
.custom-div-icon { .custom-div-icon {
background: transparent; background: transparent;
border: none; border: none;
@@ -116,347 +139,3 @@
flex-shrink: 0; flex-shrink: 0;
} }
</style> </style>
<script>
// Pass supported DXCC list from PHP to JavaScript
const supportedDxccs = <?php echo json_encode(array_keys($supported_dxccs)); ?>;
// Wait for jQuery to be loaded
function initMap() {
let map = null;
let markers = [];
let geojsonLayers = []; // Store multiple GeoJSON layers
let allQsos = []; // Store all QSOs for filtering
let legendAdded = false; // Track if legend has been added
let legendControl = null; // Store legend control for updates
// Enable/disable load button based on country selection
$('#countrySelect, #locationSelect').on('change', function() {
const countrySelected = $('#countrySelect').val();
$('#loadMapBtn').prop('disabled', !countrySelected);
$('#showOnlyOutside').prop('disabled', !countrySelected);
$('#mapContainer, #noDataMessage').hide();
});
// Handle checkbox change
$('#showOnlyOutside').on('change', function() {
if (allQsos.length > 0) {
filterAndDisplayMarkers(allQsos, $(this).is(':checked'));
}
});
// Load map when button is clicked
$('#loadMapBtn').on('click', function() {
const country = $('#countrySelect').val();
const dxcc = $('#countrySelect option:selected').data('dxcc');
const stationId = $('#locationSelect').val();
if (!country) return;
// Fetch QSO data
const loadingText = country === 'all' ? 'Loading QSOs for all countries (this may take a moment)...' : 'Loading QSO data...';
$('#loadingSpinner').removeClass('d-none');
$('#loadingText').text(loadingText).removeClass('d-none');
$('#loadMapBtn').prop('disabled', true);
// Set timeout for long-running requests
const timeout = setTimeout(function() {
$('#loadingText').text('Still loading... Processing large dataset, please wait...');
}, 5000);
$.ajax({
url: '<?php echo site_url("map/get_qsos_for_country"); ?>',
method: 'POST',
dataType: 'json',
data: {
country: country,
dxcc: dxcc,
station_id: stationId
},
success: function(response) {
clearTimeout(timeout);
$('#loadingSpinner').addClass('d-none');
$('#loadingText').addClass('d-none');
$('#loadMapBtn').prop('disabled', false);
// Check if response is a string and parse it if needed
if (typeof response === 'string') {
try {
response = JSON.parse(response);
} catch (e) {
alert('Error parsing response: ' + e.message);
return;
}
}
if (response.error) {
alert('Error: ' + response.error);
return;
}
if (!Array.isArray(response)) {
console.log('Response is not an array:', response);
alert('Error: Expected an array of QSOs but received something else');
return;
}
if (response.length === 0) {
$('#noDataMessage').show();
return;
}
// Store all QSOs and initialize map
allQsos = response;
const showOnlyOutside = $('#showOnlyOutside').is(':checked');
filterAndDisplayMarkers(allQsos, showOnlyOutside);
}
}).fail(function() {
clearTimeout(timeout);
$('#loadingSpinner').addClass('d-none');
$('#loadingText').addClass('d-none');
$('#loadMapBtn').prop('disabled', false);
alert('Failed to load QSO data. Please try again.');
});
});
function filterAndDisplayMarkers(qsos, showOnlyOutside = false) {
// Clear existing markers and layers
clearMap();
// Filter QSOs if checkbox is checked
const filteredQsos = showOnlyOutside ? qsos.filter(qso => qso.inside_geojson === false) : qsos;
// Create map if it doesn't exist
if (!map) {
map = L.map('mapgeojson').setView([40, 0], 2);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
}
// Check if we have country boundaries
const selectedOption = $('#countrySelect option:selected');
const dxcc = selectedOption.data('dxcc');
const country = $('#countrySelect').val();
// Add QSO markers first
let bounds = [];
let outsideCount = 0;
let insideCount = 0;
filteredQsos.forEach(function(qso) {
let marker;
let icon;
// Check if QSO is inside GeoJSON boundary
if (qso.inside_geojson === false) {
// Create red X icon for QSOs outside GeoJSON
icon = L.divIcon({
html: '<div style="background-color: #ff0000; color: white; width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 16px; border: 2px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.5);">✕</div>',
iconSize: [24, 24],
className: 'custom-div-icon'
});
outsideCount++;
} else {
// Create green checkmark icon for QSOs inside GeoJSON
icon = L.divIcon({
html: '<div style="background-color: #28a745; color: white; width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 16px; border: 2px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.5);">✓</div>',
iconSize: [24, 24],
className: 'custom-div-icon'
});
insideCount++;
}
marker = L.marker([qso.lat, qso.lng], { icon: icon })
.bindPopup(qso.popup +
(qso.inside_geojson === false ? '<br><span style="color: red;"><strong>⚠ Outside country boundaries</strong></span>' :
'<br><span style="color: green;"><strong>✓ Inside country boundaries</strong></span>'))
.addTo(map);
markers.push(marker);
bounds.push([qso.lat, qso.lng]);
});
// Try to load GeoJSON for the country/countries
if (dxcc && supportedDxccs.includes(parseInt(dxcc))) {
// Single country GeoJSON
$.ajax({
url: base_url + "index.php/map/get_country_geojson/",
type: 'post',
data: { dxcc: dxcc },
success: function(geojson) {
if (geojson && !geojson.error) {
const layer = L.geoJSON(geojson, {
style: {
color: '#ff0000',
weight: 2,
opacity: 0.5,
fillOpacity: 0.1
}
}).addTo(map);
geojsonLayers.push(layer);
// Fit map to show both GeoJSON and markers
setTimeout(function() {
const geoBounds = layer.getBounds();
if (bounds.length > 0) {
const markerBounds = L.latLngBounds(bounds);
// Combine bounds
geoBounds.extend(markerBounds);
}
map.fitBounds(geoBounds, { padding: [20, 20] });
}, 100);
} else {
// No GeoJSON, fit to markers only
if (bounds.length > 0) {
const markerBounds = L.latLngBounds(bounds);
map.fitBounds(markerBounds, { padding: [50, 50] });
}
}
},
error: function() {
// GeoJSON failed to load, fit to markers only
if (bounds.length > 0) {
const markerBounds = L.latLngBounds(bounds);
map.fitBounds(markerBounds, { padding: [50, 50] });
}
}
});
} else {
// No GeoJSON support, fit to markers only
if (bounds.length > 0) {
const markerBounds = L.latLngBounds(bounds);
map.fitBounds(markerBounds, { padding: [50, 50] });
}
}
$('#mapContainer').show();
// Add legend to the map only once
if (!legendAdded) {
addLegend(insideCount, outsideCount, qsos.length, showOnlyOutside);
legendAdded = true;
} else {
// Update existing legend counts
updateLegend(insideCount, outsideCount, qsos.length, showOnlyOutside);
}
// Force map to recalculate its size
setTimeout(function() {
if (map) {
map.invalidateSize();
// Re-fit bounds after size invalidation
if (bounds.length > 0) {
const markerBounds = L.latLngBounds(bounds);
map.fitBounds(markerBounds, { padding: [50, 50] });
}
}
}, 100);
}
function addLegend(insideCount, outsideCount, totalCount, showOnlyOutside) {
const legend = L.control({ position: 'topright' });
legend.onAdd = function(map) {
const div = L.DomUtil.create('div', 'legend');
let html = '<h4>Legend</h4>';
// Inside boundaries
html += '<div class="legend-item">';
html += '<div class="legend-icon">';
html += '<div style="background-color: #28a745; color: white; width: 20px; height: 20px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold; font-size: 12px; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.3);">✓</div>';
html += '</div>';
html += '<span>Inside boundaries <strong>(' + insideCount + ')</strong></span>';
html += '</div>';
// Outside boundaries
html += '<div class="legend-item">';
html += '<div class="legend-icon">';
html += '<div style="background-color: #ff0000; color: white; width: 20px; height: 20px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold; font-size: 12px; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.3);">✕</div>';
html += '</div>';
html += '<span>Outside boundaries <strong>(' + outsideCount + ')</strong></span>';
html += '</div>';
// GeoJSON boundaries
html += '<div class="legend-item">';
html += '<div class="legend-icon">';
html += '<svg width="20" height="3"><line x1="0" y1="1.5" x2="20" y2="1.5" stroke="#ff0000" stroke-width="2"/></svg>';
html += '</div>';
html += '<span>Country/State boundaries</span>';
html += '</div>';
// Total QSOs (shown differently when filtering)
if (showOnlyOutside) {
html += '<div style="margin-top: 10px; padding-top: 8px; border-top: 1px solid #ddd; font-size: 12px;">';
html += '<em>Showing ' + outsideCount + ' of ' + totalCount + ' total QSOs</em>';
html += '</div>';
} else {
html += '<div style="margin-top: 10px; padding-top: 8px; border-top: 1px solid #ddd; font-size: 12px;">';
html += '<em>Total: ' + totalCount + ' QSOs with 6+ char grids</em>';
html += '</div>';
}
div.innerHTML = html;
// Prevent map events on the legend
L.DomEvent.disableClickPropagation(div);
L.DomEvent.disableScrollPropagation(div);
return div;
};
legendControl = legend;
legend.addTo(map);
}
function updateLegend(insideCount, outsideCount, totalCount, showOnlyOutside) {
if (!legendControl) return;
// Remove the legend and re-add it with updated counts
map.removeControl(legendControl);
addLegend(insideCount, outsideCount, totalCount, showOnlyOutside);
}
function clearMap() {
// Remove existing markers
markers.forEach(function(marker) {
map.removeLayer(marker);
});
markers = [];
// Remove all GeoJSON layers
geojsonLayers.forEach(function(layer) {
map.removeLayer(layer);
});
geojsonLayers = [];
}
}
// Check if jQuery is loaded, if not wait for it
if (typeof $ === 'undefined') {
// jQuery not yet loaded, add event listener
document.addEventListener('DOMContentLoaded', function() {
if (typeof $ === 'undefined') {
// Wait for jQuery to load
var checkJQuery = setInterval(function() {
if (typeof $ !== 'undefined') {
clearInterval(checkJQuery);
initMap();
}
}, 100);
} else {
initMap();
}
});
} else {
// jQuery already loaded
$(document).ready(function() {
initMap();
});
}
</script>

View File

@@ -0,0 +1,527 @@
let maidenhead;
let zonemarkers = [];
let ituzonemarkers = [];
let map = null;
// Wait for jQuery to be loaded
function initMap() {
let markers = [];
let geojsonLayers = []; // Store multiple GeoJSON layers
let allQsos = []; // Store all QSOs for filtering
let legendAdded = false; // Track if legend has been added
let legendControl = null; // Store legend control for updates
// Enable/disable load button based on country selection
$('#countrySelect, #locationSelect').on('change', function() {
const countrySelected = $('#countrySelect').val();
$('#loadMapBtn').prop('disabled', !countrySelected);
$('#showOnlyOutside').prop('disabled', !countrySelected);
$('#mapContainer, #noDataMessage').hide();
});
// Handle checkbox change
$('#showOnlyOutside').on('change', function() {
if (allQsos.length > 0) {
filterAndDisplayMarkers(allQsos, $(this).is(':checked'));
}
});
// Load map when button is clicked
$('#loadMapBtn').on('click', function() {
const country = $('#countrySelect').val();
const dxcc = $('#countrySelect option:selected').data('dxcc');
const stationId = $('#locationSelect').val();
if (!country) return;
// Fetch QSO data
const loadingText = country === 'all' ? 'Loading QSOs for all countries (this may take a moment)...' : 'Loading QSO data...';
$('#loadingSpinner').removeClass('d-none');
$('#loadingText').text(loadingText).removeClass('d-none');
$('#loadMapBtn').prop('disabled', true);
// Set timeout for long-running requests
const timeout = setTimeout(function() {
$('#loadingText').text('Still loading... Processing large dataset, please wait...');
}, 5000);
$.ajax({
url: base_url + 'index.php/map/get_qsos_for_country',
method: 'POST',
dataType: 'json',
data: {
country: country,
dxcc: dxcc,
station_id: stationId
},
success: function(response) {
clearTimeout(timeout);
$('#loadingSpinner').addClass('d-none');
$('#loadingText').addClass('d-none');
$('#loadMapBtn').prop('disabled', false);
// Check if response is a string and parse it if needed
if (typeof response === 'string') {
try {
response = JSON.parse(response);
} catch (e) {
alert('Error parsing response: ' + e.message);
return;
}
}
if (response.error) {
alert('Error: ' + response.error);
return;
}
if (!Array.isArray(response)) {
console.log('Response is not an array:', response);
alert('Error: Expected an array of QSOs but received something else');
return;
}
if (response.length === 0) {
$('#noDataMessage').show();
return;
}
// Store all QSOs and initialize map
allQsos = response;
const showOnlyOutside = $('#showOnlyOutside').is(':checked');
filterAndDisplayMarkers(allQsos, showOnlyOutside);
}
}).fail(function() {
clearTimeout(timeout);
$('#loadingSpinner').addClass('d-none');
$('#loadingText').addClass('d-none');
$('#loadMapBtn').prop('disabled', false);
alert('Failed to load QSO data. Please try again.');
});
});
function filterAndDisplayMarkers(qsos, showOnlyOutside = false) {
// Clear existing markers and layers
clearMap();
// Filter QSOs if checkbox is checked
const filteredQsos = showOnlyOutside ? qsos.filter(qso => qso.inside_geojson === false) : qsos;
// Create map if it doesn't exist
if (!map) {
map = L.map('mapgeojson').setView([40, 0], 2);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
}
maidenhead = L.maidenheadqrb().addTo(map);
map.on('mousemove', onMapMove);
$('.cohidden').show();
if (typeof gridsquare_layer !== 'undefined') {
toggleGridsquares(gridsquare_layer);
} else {
toggleGridsquares(false);
}
// Check if we have country boundaries
const selectedOption = $('#countrySelect option:selected');
const dxcc = selectedOption.data('dxcc');
const country = $('#countrySelect').val();
// Add QSO markers first
let bounds = [];
let outsideCount = 0;
let insideCount = 0;
filteredQsos.forEach(function(qso) {
let marker;
let icon;
// Check if QSO is inside GeoJSON boundary
if (qso.inside_geojson === false) {
// Create red X icon for QSOs outside GeoJSON
icon = L.divIcon({
html: '<div style="background-color: #ff0000; color: white; width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 16px; border: 2px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.5);">✕</div>',
iconSize: [24, 24],
className: 'custom-div-icon'
});
outsideCount++;
} else {
// Create green checkmark icon for QSOs inside GeoJSON
icon = L.divIcon({
html: '<div style="background-color: #28a745; color: white; width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 16px; border: 2px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.5);">✓</div>',
iconSize: [24, 24],
className: 'custom-div-icon'
});
insideCount++;
}
marker = L.marker([qso.lat, qso.lng], { icon: icon })
.bindPopup(qso.popup +
(qso.inside_geojson === false ? '<br><span style="color: red;"><strong>⚠ Outside country boundaries</strong></span>' :
'<br><span style="color: green;"><strong>✓ Inside country boundaries</strong></span>'))
.addTo(map);
markers.push(marker);
bounds.push([qso.lat, qso.lng]);
});
// Try to load GeoJSON for the country/countries
if (dxcc && supportedDxccs.includes(parseInt(dxcc))) {
// Single country GeoJSON
$.ajax({
url: base_url + "index.php/map/get_country_geojson/",
type: 'post',
data: { dxcc: dxcc },
success: function(geojson) {
if (geojson && !geojson.error) {
const layer = L.geoJSON(geojson, {
style: {
color: '#ff0000',
weight: 2,
opacity: 0.5,
fillOpacity: 0.1
}
}).addTo(map);
geojsonLayers.push(layer);
// Fit map to show both GeoJSON and markers
setTimeout(function() {
const geoBounds = layer.getBounds();
if (bounds.length > 0) {
const markerBounds = L.latLngBounds(bounds);
// Combine bounds
geoBounds.extend(markerBounds);
}
map.fitBounds(geoBounds, { padding: [20, 20] });
}, 100);
} else {
// No GeoJSON, fit to markers only
if (bounds.length > 0) {
const markerBounds = L.latLngBounds(bounds);
map.fitBounds(markerBounds, { padding: [50, 50] });
}
}
},
error: function() {
// GeoJSON failed to load, fit to markers only
if (bounds.length > 0) {
const markerBounds = L.latLngBounds(bounds);
map.fitBounds(markerBounds, { padding: [50, 50] });
}
}
});
} else {
// No GeoJSON support, fit to markers only
if (bounds.length > 0) {
const markerBounds = L.latLngBounds(bounds);
map.fitBounds(markerBounds, { padding: [50, 50] });
}
}
$('#mapContainer').show();
// Add legend to the map only once
if (!legendAdded) {
addLegend(insideCount, outsideCount, qsos.length, showOnlyOutside);
legendAdded = true;
} else {
// Update existing legend counts
updateLegend(insideCount, outsideCount, qsos.length, showOnlyOutside);
}
// Force map to recalculate its size
setTimeout(function() {
if (map) {
map.invalidateSize();
// Re-fit bounds after size invalidation
if (bounds.length > 0) {
const markerBounds = L.latLngBounds(bounds);
map.fitBounds(markerBounds, { padding: [50, 50] });
}
}
}, 100);
}
function addLegend(insideCount, outsideCount, totalCount, showOnlyOutside) {
const legend = L.control({ position: 'topright' });
legend.onAdd = function(map) {
const div = L.DomUtil.create('div', 'legend');
let html = '<h4>Legend</h4>';
// Inside boundaries
html += '<div class="legend-item">';
html += '<div class="legend-icon">';
html += '<div style="background-color: #28a745; color: white; width: 20px; height: 20px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold; font-size: 12px; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.3);">✓</div>';
html += '</div>';
html += '<span>Inside boundaries <strong>(' + insideCount + ')</strong></span>';
html += '</div>';
// Outside boundaries
html += '<div class="legend-item">';
html += '<div class="legend-icon">';
html += '<div style="background-color: #ff0000; color: white; width: 20px; height: 20px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold; font-size: 12px; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.3);">✕</div>';
html += '</div>';
html += '<span>Outside boundaries <strong>(' + outsideCount + ')</strong></span>';
html += '</div>';
// GeoJSON boundaries
html += '<div class="legend-item">';
html += '<div class="legend-icon">';
html += '<svg width="20" height="3"><line x1="0" y1="1.5" x2="20" y2="1.5" stroke="#ff0000" stroke-width="2"/></svg>';
html += '</div>';
html += '<span>Country/State boundaries</span>';
html += '</div>';
// Total QSOs (shown differently when filtering)
if (showOnlyOutside) {
html += '<div style="margin-top: 10px; padding-top: 8px; border-top: 1px solid #ddd; font-size: 12px;">';
html += '<em>Showing ' + outsideCount + ' of ' + totalCount + ' total QSOs</em>';
html += '</div>';
} else {
html += '<div style="margin-top: 10px; padding-top: 8px; border-top: 1px solid #ddd; font-size: 12px;">';
html += '<em>Total: ' + totalCount + ' QSOs with 6+ char grids</em>';
html += '</div>';
}
html += '<br />';
html += '<h4>Toggle layers</h4>';
html += '<input type="checkbox" onclick="toggleGridsquares(this.checked)" ' + (typeof gridsquare_layer !== 'undefined' && gridsquare_layer ? 'checked' : '') + ' style="outline: none;"><span> ' + lang_gen_hamradio_gridsquares + '</span><br>';
html += '<input type="checkbox" onclick="toggleCqZones(this.checked)" ' + (typeof cqzones_layer !== 'undefined' && cqzones_layer ? 'checked' : '') + ' style="outline: none;"><span> ' + lang_gen_hamradio_cq_zones + '</span><br>';
html += '<input type="checkbox" onclick="toggleItuZones(this.checked)" ' + (typeof ituzones_layer !== 'undefined' && ituzones_layer ? 'checked' : '') + ' style="outline: none;"><span> ' + lang_gen_hamradio_itu_zones + '</span><br>';
div.innerHTML = html;
// Prevent map events on the legend
L.DomEvent.disableClickPropagation(div);
L.DomEvent.disableScrollPropagation(div);
return div;
};
legendControl = legend;
legend.addTo(map);
}
function updateLegend(insideCount, outsideCount, totalCount, showOnlyOutside) {
if (!legendControl) return;
// Remove the legend and re-add it with updated counts
map.removeControl(legendControl);
addLegend(insideCount, outsideCount, totalCount, showOnlyOutside);
}
function clearMap() {
// Remove existing markers
markers.forEach(function(marker) {
map.removeLayer(marker);
});
markers = [];
// Remove all GeoJSON layers
geojsonLayers.forEach(function(layer) {
map.removeLayer(layer);
});
geojsonLayers = [];
}
}
// Check if jQuery is loaded, if not wait for it
if (typeof $ === 'undefined') {
// jQuery not yet loaded, add event listener
document.addEventListener('DOMContentLoaded', function() {
if (typeof $ === 'undefined') {
// Wait for jQuery to load
var checkJQuery = setInterval(function() {
if (typeof $ !== 'undefined') {
clearInterval(checkJQuery);
initMap();
}
}, 100);
} else {
initMap();
}
});
} else {
// jQuery already loaded
$(document).ready(function() {
initMap();
});
}
function toggleCqZones(bool) {
if(!bool) {
zonemarkers.forEach(function (item) {
map.removeLayer(item);
});
if (geojson != undefined) {
map.removeLayer(geojson);
}
} else {
geojson = L.geoJson(zonestuff, {style: style}).addTo(map);
for (var i = 0; i < cqzonenames.length; i++) {
var title = '<span class="grid-text" style="cursor: default"><font style="color: \'white\'; font-size: 1.5em; font-weight: 900;">' + (Number(i)+Number(1)) + '</font></span>';
var myIcon = L.divIcon({className: 'my-div-icon', html: title});
var marker = L.marker(
[cqzonenames[i][0], cqzonenames[i][1]], {
icon: myIcon,
title: (Number(i)+Number(1)),
zIndex: 1000,
}
).addTo(map);
zonemarkers.push(marker);
}
}
}
function toggleItuZones(bool) {
if(!bool) {
ituzonemarkers.forEach(function (item) {
map.removeLayer(item);
});
if (itugeojson != undefined) {
map.removeLayer(itugeojson);
}
} else {
itugeojson = L.geoJson(ituzonestuff, {style: style}).addTo(map);
for (var i = 0; i < ituzonenames.length; i++) {
var title = '<span class="grid-text" style="cursor: default"><font style="color: \'white\'; font-size: 1.5em; font-weight: 900;">' + (Number(i)+Number(1)) + '</font></span>';
var myIcon = L.divIcon({className: 'my-div-icon', html: title});
var marker = L.marker(
[ituzonenames[i][0], ituzonenames[i][1]], {
icon: myIcon,
title: (Number(i)+Number(1)),
zIndex: 1000,
}
).addTo(map);
ituzonemarkers.push(marker);
}
}
}
function toggleGridsquares(bool) {
if(!bool) {
map.removeLayer(maidenhead);
} else {
maidenhead.addTo(map);
}
};
const ituzonenames = [
["60","-160"],
["55","-125"],
["55","-100"],
["55","-78"],
["73","-40"],
["40","-119"],
["40","-100"],
["40","-80"],
["55","-60"],
["20","-102"],
["21","-75"],
["-3","-72"],
["-5","-45"],
["-30","-65"],
["-25","-45"],
["-50","-65"],
["61","-26"],
["70","10"],
["70","40"],
["70","62.5"],
["70","82.5"],
["70","100"],
["70","122.5"],
["70","142.5"],
["70","162.5"],
["70","180"],
["52","2"],
["45","18"],
["53","36"],
["53","62.5"],
["53","82.5"],
["53","100"],
["53","122.5"],
["53","142"],
["55","160"],
["35","-25"],
["35","0"],
["27.5","22.5"],
["27","42"],
["32","56"],
["10","75"],
["39","82.5"],
["33","100"],
["33","118"],
["33","140"],
["15","-10"],
["12.5","22"],
["5","40"],
["15","100"],
["10","120"],
["-4","150"],
["-7","17"],
["-12.5","45"],
["-2","115"],
["-20","140"],
["-20","170"],
["-30","24"],
["-25","120"],
["-40","140"],
["-40","170"],
["15","-170"],
["-15","-170"],
["-15","-135"],
["10","140"],
["10","162"],
["-23","-11"],
["-70","10"],
["-47.5","60"],
["-70","70"],
["-70","130"],
["-70","-170"],
["-70","-110"],
["-70","-050"],
["-82.5","0"],
["82.5","0"],
["40","-150"],
["15","-135"],
["-15","-95"],
["-40","-160"],
["-40","-125"],
["-40","-90"],
["50","-30"],
["25","-47.5"],
["-45","-40"],
["-45","10"],
["-25","70"],
["-25","95"],
["-50","95"],
["-54","140"],
["39","165"]
];
function style(feature) {
var bordercolor = "black";
if (isDarkModeTheme()) {
bordercolor = "white";
}
return {
fillColor: "white",
fillOpacity: 0,
opacity: 0.65,
color: bordercolor,
weight: 1,
};
}