This commit is contained in:
Szymon Porwolik
2025-11-06 02:40:56 +01:00
parent ad9816cfa3
commit 41fdb5f3ef
5 changed files with 1406 additions and 5 deletions

View File

@@ -83,8 +83,32 @@
var lang_bandmap_no_spots_filters = "<?= __("No spots found for selected filters"); ?>"; var lang_bandmap_no_spots_filters = "<?= __("No spots found for selected filters"); ?>";
var lang_bandmap_error_loading = "<?= __("Error loading spots. Please try again."); ?>"; var lang_bandmap_error_loading = "<?= __("Error loading spots. Please try again."); ?>";
// DX Map translation strings
var lang_bandmap_draw_spotters = "<?= __("Draw Spotters"); ?>";
var lang_bandmap_your_qth = "<?= __("Your QTH"); ?>";
var lang_bandmap_callsign = "<?= __("Callsign"); ?>";
var lang_bandmap_frequency = "<?= __("Frequency"); ?>";
var lang_bandmap_mode = "<?= __("Mode"); ?>";
var lang_bandmap_band = "<?= __("Band"); ?>";
// Enable compact radio status display for bandmap page // Enable compact radio status display for bandmap page
window.CAT_COMPACT_MODE = true; window.CAT_COMPACT_MODE = true;
// Map configuration (matches QSO map settings)
var map_tile_server = '<?php echo $this->optionslib->get_option('option_map_tile_server');?>';
var map_tile_server_copyright = '<?php echo $this->optionslib->get_option('option_map_tile_server_copyright');?>';
var icon_dot_url = "<?php echo base_url();?>assets/images/dot.png";
// User gridsquare for home position marker
var user_gridsquare = '<?php
if (($this->optionslib->get_option("station_gridsquare") ?? "") != "") {
echo $this->optionslib->get_option("station_gridsquare");
} else if (null !== $this->config->item("locator")) {
echo $this->config->item("locator");
} else {
echo "IO91WM";
}
?>';
</script> </script>
<link rel="stylesheet" type="text/css" href="<?php echo base_url(); ?>assets/css/bandmap_list.css" /> <link rel="stylesheet" type="text/css" href="<?php echo base_url(); ?>assets/css/bandmap_list.css" />
@@ -100,7 +124,7 @@
<a href="<?php echo base_url(); ?>" title="<?= __("Return to Home"); ?>"> <a href="<?php echo base_url(); ?>" title="<?= __("Return to Home"); ?>">
<img class="headerLogo me-2 bandmap-logo-fullscreen" src="<?php echo base_url(); ?>assets/logo/<?php echo $this->optionslib->get_logo('header_logo'); ?>.png" alt="Logo" style="height: 32px; width: auto; cursor: pointer;" /> <img class="headerLogo me-2 bandmap-logo-fullscreen" src="<?php echo base_url(); ?>assets/logo/<?php echo $this->optionslib->get_logo('header_logo'); ?>.png" alt="Logo" style="height: 32px; width: auto; cursor: pointer;" />
</a> </a>
<h5 class="mb-0"><?= __("DX Cluster - spot list"); ?></h5> <h5 class="mb-0"><?= __("DX Cluster"); ?></h5>
</div> </div>
<div class="d-flex align-items-center gap-3"> <div class="d-flex align-items-center gap-3">
<a href="https://www.wavelog.org" target="_blank" class="fullscreen-wavelog-text" style="display: none; font-weight: 500; color: var(--bs-body-color); text-decoration: none;">www.wavelog.org</a> <a href="https://www.wavelog.org" target="_blank" class="fullscreen-wavelog-text" style="display: none; font-weight: 500; color: var(--bs-body-color); text-decoration: none;">www.wavelog.org</a>
@@ -391,12 +415,19 @@
<i class="fas fa-bolt"></i> <span class="d-none d-sm-inline"><?= __("Fresh"); ?></span> <i class="fas fa-bolt"></i> <span class="d-none d-sm-inline"><?= __("Fresh"); ?></span>
</button> </button>
</div> </div>
<!-- DX Map Button (right side) -->
<div class="ms-auto">
<button class="btn btn-sm btn-primary" type="button" id="dxMapButton" title="<?= __("Open DX Map view"); ?>">
<i class="fas fa-map-marked-alt"></i> <span class="d-none d-sm-inline"><?= __("DX Map"); ?></span>
</button>
</div>
</div> </div>
<!-- Row 5: Status Bar (70%) and Search (30%) --> <!-- Row 5: Status Bar (70%) and Search (30%) -->
<div class="d-flex flex-wrap align-items-center gap-2 mb-2"> <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<!-- Status Bar - 70% --> <!-- Status Bar - 70% -->
<div style="flex: 1 1 auto; min-width: 300px; max-width: 70%;"> <div style="flex: 1 1 0; min-width: 300px;">
<div class="status-bar"> <div class="status-bar">
<div class="status-bar-inner"> <div class="status-bar-inner">
<div class="status-bar-left"> <div class="status-bar-left">
@@ -411,13 +442,21 @@
</div> </div>
<!-- Search Input - 30% --> <!-- Search Input - 30% -->
<div class="input-group input-group-sm" style="flex: 0 1 auto; min-width: 200px; max-width: 400px;"> <div class="input-group input-group-sm" style="flex: 0 0 auto; min-width: 200px; max-width: 400px;">
<input type="text" class="form-control" id="spotSearchInput" placeholder="<?= __("Search spots..."); ?>" aria-label="<?= __("Search"); ?>"> <input type="text" class="form-control" id="spotSearchInput" placeholder="<?= __("Search spots..."); ?>" aria-label="<?= __("Search"); ?>">
<span class="input-group-text search-icon-clickable" id="searchIcon"><i class="fas fa-search"></i></span> <span class="input-group-text search-icon-clickable" id="searchIcon"><i class="fas fa-search"></i></span>
</div> </div>
</div> </div>
</div> </div>
<!-- DX Map Container (initially hidden) -->
<div id="dxMapContainer" style="display: none; margin-bottom: 15px;">
<div id="dxMap" style="height: 345px; width: 100%; border: 1px solid #dee2e6; border-radius: 4px;"></div>
<div style="font-size: 11px; color: #6c757d; text-align: center; margin-top: 5px; font-style: italic;">
<i class="fas fa-info-circle"></i> <?= __("Note: Map shows DXCC entity locations, not actual spot locations"); ?>
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped table-hover spottable"> <table class="table table-striped table-hover spottable">
<thead> <thead>

View File

@@ -1466,6 +1466,7 @@ mymap.on('mousemove', onQsoMapMove);
<!--- Bandmap CAT Integration ---> <!--- Bandmap CAT Integration --->
<?php if ($this->uri->segment(1) == "bandmap" && $this->uri->segment(2) == "list") { ?> <?php if ($this->uri->segment(1) == "bandmap" && $this->uri->segment(2) == "list") { ?>
<script type="text/javascript" src="<?php echo base_url(); ?>assets/js/cat.js?v=<?php echo time(); ?>"></script> <script type="text/javascript" src="<?php echo base_url(); ?>assets/js/cat.js?v=<?php echo time(); ?>"></script>
<script type="text/javascript" src="<?php echo base_url(); ?>assets/js/leaflet.polylineDecorator.js?v=<?php echo time(); ?>"></script>
<script type="text/javascript" src="<?php echo base_url(); ?>assets/js/sections/bandmap_list.js?v=<?php echo time(); ?>"></script> <script type="text/javascript" src="<?php echo base_url(); ?>assets/js/sections/bandmap_list.js?v=<?php echo time(); ?>"></script>
<?php } ?> <?php } ?>

View File

@@ -282,14 +282,14 @@
<li class="nav-item dropdown"> <!-- TOOLS --> <li class="nav-item dropdown"> <!-- TOOLS -->
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#"><?= __("Tools"); ?></a> <a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#"><?= __("Tools"); ?></a>
<ul class="dropdown-menu header-dropdown"> <ul class="dropdown-menu header-dropdown">
<li><a class="dropdown-item" href="<?php echo site_url('bandmap/list'); ?>" title="DX Cluster"><i class="fa fa-tower-broadcast"></i> <?= __("DX Cluster"); ?></a></li>
<div class="dropdown-divider"></div>
<li><a class="dropdown-item" href="<?php echo site_url('dxcalendar'); ?>" title="DX Calendar"><i class="fas fa-calendar"></i> <?= __("DX Calendar"); ?></a></li> <li><a class="dropdown-item" href="<?php echo site_url('dxcalendar'); ?>" title="DX Calendar"><i class="fas fa-calendar"></i> <?= __("DX Calendar"); ?></a></li>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<li><a class="dropdown-item" href="<?php echo site_url('contestcalendar'); ?>" title="Contest Calendar"><i class="fas fa-calendar"></i> <?= __("Contest Calendar"); ?></a></li> <li><a class="dropdown-item" href="<?php echo site_url('contestcalendar'); ?>" title="Contest Calendar"><i class="fas fa-calendar"></i> <?= __("Contest Calendar"); ?></a></li>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<li><a class="dropdown-item" href="<?php echo site_url('hamsat'); ?>" title="Hams.at"><i class="fas fa-list"></i> Hams.at</a></li> <li><a class="dropdown-item" href="<?php echo site_url('hamsat'); ?>" title="Hams.at"><i class="fas fa-list"></i> Hams.at</a></li>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<li><a class="dropdown-item" href="<?php echo site_url('bandmap/list'); ?>" title="Bandmap"><i class="fa fa-id-card"></i> <?= __("Bandmap"); ?></a></li>
<div class="dropdown-divider"></div>
<li><a class="dropdown-item" href="<?php echo site_url('sattimers'); ?>" title="SAT Timers"><i class="fas fa-satellite"></i> <?= __("SAT Timers"); ?></a></li> <li><a class="dropdown-item" href="<?php echo site_url('sattimers'); ?>" title="SAT Timers"><i class="fas fa-satellite"></i> <?= __("SAT Timers"); ?></a></li>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<li><a class="dropdown-item" href="<?php echo site_url('satellite/flightpath'); ?>" title="Show Satellite Flight Path"><i class="fas fa-satellite"></i> <?= __("Satellite Flightpath"); ?></a></li> <li><a class="dropdown-item" href="<?php echo site_url('satellite/flightpath'); ?>" title="Show Satellite Flight Path"><i class="fas fa-satellite"></i> <?= __("Satellite Flightpath"); ?></a></li>

View File

@@ -0,0 +1,478 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(require('leaflet')) :
typeof define === 'function' && define.amd ? define(['leaflet'], factory) :
(factory(global.L));
}(this, (function (L$1) { 'use strict';
L$1 = L$1 && L$1.hasOwnProperty('default') ? L$1['default'] : L$1;
// functional re-impl of L.Point.distanceTo,
// with no dependency on Leaflet for easier testing
function pointDistance(ptA, ptB) {
var x = ptB.x - ptA.x;
var y = ptB.y - ptA.y;
return Math.sqrt(x * x + y * y);
}
var computeSegmentHeading = function computeSegmentHeading(a, b) {
return (Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI + 90 + 360) % 360;
};
var asRatioToPathLength = function asRatioToPathLength(_ref, totalPathLength) {
var value = _ref.value,
isInPixels = _ref.isInPixels;
return isInPixels ? value / totalPathLength : value;
};
function parseRelativeOrAbsoluteValue(value) {
if (typeof value === 'string' && value.indexOf('%') !== -1) {
return {
value: parseFloat(value) / 100,
isInPixels: false
};
}
var parsedValue = value ? parseFloat(value) : 0;
return {
value: parsedValue,
isInPixels: parsedValue > 0
};
}
var pointsEqual = function pointsEqual(a, b) {
return a.x === b.x && a.y === b.y;
};
function pointsToSegments(pts) {
return pts.reduce(function (segments, b, idx, points) {
// this test skips same adjacent points
if (idx > 0 && !pointsEqual(b, points[idx - 1])) {
var a = points[idx - 1];
var distA = segments.length > 0 ? segments[segments.length - 1].distB : 0;
var distAB = pointDistance(a, b);
segments.push({
a: a,
b: b,
distA: distA,
distB: distA + distAB,
heading: computeSegmentHeading(a, b)
});
}
return segments;
}, []);
}
function projectPatternOnPointPath(pts, pattern) {
// 1. split the path into segment infos
var segments = pointsToSegments(pts);
var nbSegments = segments.length;
if (nbSegments === 0) {
return [];
}
var totalPathLength = segments[nbSegments - 1].distB;
var offset = asRatioToPathLength(pattern.offset, totalPathLength);
var endOffset = asRatioToPathLength(pattern.endOffset, totalPathLength);
var repeat = asRatioToPathLength(pattern.repeat, totalPathLength);
var repeatIntervalPixels = totalPathLength * repeat;
var startOffsetPixels = offset > 0 ? totalPathLength * offset : 0;
var endOffsetPixels = endOffset > 0 ? totalPathLength * endOffset : 0;
// 2. generate the positions of the pattern as offsets from the path start
var positionOffsets = [];
var positionOffset = startOffsetPixels;
do {
positionOffsets.push(positionOffset);
positionOffset += repeatIntervalPixels;
} while (repeatIntervalPixels > 0 && positionOffset < totalPathLength - endOffsetPixels);
// 3. projects offsets to segments
var segmentIndex = 0;
var segment = segments[0];
return positionOffsets.map(function (positionOffset) {
// find the segment matching the offset,
// starting from the previous one as offsets are ordered
while (positionOffset > segment.distB && segmentIndex < nbSegments - 1) {
segmentIndex++;
segment = segments[segmentIndex];
}
var segmentRatio = (positionOffset - segment.distA) / (segment.distB - segment.distA);
return {
pt: interpolateBetweenPoints(segment.a, segment.b, segmentRatio),
heading: segment.heading
};
});
}
/**
* Finds the point which lies on the segment defined by points A and B,
* at the given ratio of the distance from A to B, by linear interpolation.
*/
function interpolateBetweenPoints(ptA, ptB, ratio) {
if (ptB.x !== ptA.x) {
return {
x: ptA.x + ratio * (ptB.x - ptA.x),
y: ptA.y + ratio * (ptB.y - ptA.y)
};
}
// special case where points lie on the same vertical axis
return {
x: ptA.x,
y: ptA.y + (ptB.y - ptA.y) * ratio
};
}
(function() {
// save these original methods before they are overwritten
var proto_initIcon = L.Marker.prototype._initIcon;
var proto_setPos = L.Marker.prototype._setPos;
var oldIE = (L.DomUtil.TRANSFORM === 'msTransform');
L.Marker.addInitHook(function () {
var iconOptions = this.options.icon && this.options.icon.options;
var iconAnchor = iconOptions && this.options.icon.options.iconAnchor;
if (iconAnchor) {
iconAnchor = (iconAnchor[0] + 'px ' + iconAnchor[1] + 'px');
}
this.options.rotationOrigin = this.options.rotationOrigin || iconAnchor || 'center bottom' ;
this.options.rotationAngle = this.options.rotationAngle || 0;
// Ensure marker keeps rotated during dragging
this.on('drag', function(e) { e.target._applyRotation(); });
});
L.Marker.include({
_initIcon: function() {
proto_initIcon.call(this);
},
_setPos: function (pos) {
proto_setPos.call(this, pos);
this._applyRotation();
},
_applyRotation: function () {
if(this.options.rotationAngle) {
this._icon.style[L.DomUtil.TRANSFORM+'Origin'] = this.options.rotationOrigin;
if(oldIE) {
// for IE 9, use the 2D rotation
this._icon.style[L.DomUtil.TRANSFORM] = 'rotate(' + this.options.rotationAngle + 'deg)';
} else {
// for modern browsers, prefer the 3D accelerated version
this._icon.style[L.DomUtil.TRANSFORM] += ' rotateZ(' + this.options.rotationAngle + 'deg)';
}
}
},
setRotationAngle: function(angle) {
this.options.rotationAngle = angle;
this.update();
return this;
},
setRotationOrigin: function(origin) {
this.options.rotationOrigin = origin;
this.update();
return this;
}
});
})();
L$1.Symbol = L$1.Symbol || {};
/**
* A simple dash symbol, drawn as a Polyline.
* Can also be used for dots, if 'pixelSize' option is given the 0 value.
*/
L$1.Symbol.Dash = L$1.Class.extend({
options: {
pixelSize: 10,
pathOptions: {}
},
initialize: function initialize(options) {
L$1.Util.setOptions(this, options);
this.options.pathOptions.clickable = false;
},
buildSymbol: function buildSymbol(dirPoint, latLngs, map, index, total) {
var opts = this.options;
var d2r = Math.PI / 180;
// for a dot, nothing more to compute
if (opts.pixelSize <= 1) {
return L$1.polyline([dirPoint.latLng, dirPoint.latLng], opts.pathOptions);
}
var midPoint = map.project(dirPoint.latLng);
var angle = -(dirPoint.heading - 90) * d2r;
var a = L$1.point(midPoint.x + opts.pixelSize * Math.cos(angle + Math.PI) / 2, midPoint.y + opts.pixelSize * Math.sin(angle) / 2);
// compute second point by central symmetry to avoid unecessary cos/sin
var b = midPoint.add(midPoint.subtract(a));
return L$1.polyline([map.unproject(a), map.unproject(b)], opts.pathOptions);
}
});
L$1.Symbol.dash = function (options) {
return new L$1.Symbol.Dash(options);
};
L$1.Symbol.ArrowHead = L$1.Class.extend({
options: {
polygon: true,
pixelSize: 10,
headAngle: 60,
pathOptions: {
stroke: false,
weight: 2
}
},
initialize: function initialize(options) {
L$1.Util.setOptions(this, options);
this.options.pathOptions.clickable = false;
},
buildSymbol: function buildSymbol(dirPoint, latLngs, map, index, total) {
return this.options.polygon ? L$1.polygon(this._buildArrowPath(dirPoint, map), this.options.pathOptions) : L$1.polyline(this._buildArrowPath(dirPoint, map), this.options.pathOptions);
},
_buildArrowPath: function _buildArrowPath(dirPoint, map) {
var d2r = Math.PI / 180;
var tipPoint = map.project(dirPoint.latLng);
var direction = -(dirPoint.heading - 90) * d2r;
var radianArrowAngle = this.options.headAngle / 2 * d2r;
var headAngle1 = direction + radianArrowAngle;
var headAngle2 = direction - radianArrowAngle;
var arrowHead1 = L$1.point(tipPoint.x - this.options.pixelSize * Math.cos(headAngle1), tipPoint.y + this.options.pixelSize * Math.sin(headAngle1));
var arrowHead2 = L$1.point(tipPoint.x - this.options.pixelSize * Math.cos(headAngle2), tipPoint.y + this.options.pixelSize * Math.sin(headAngle2));
return [map.unproject(arrowHead1), dirPoint.latLng, map.unproject(arrowHead2)];
}
});
L$1.Symbol.arrowHead = function (options) {
return new L$1.Symbol.ArrowHead(options);
};
L$1.Symbol.Marker = L$1.Class.extend({
options: {
markerOptions: {},
rotate: false
},
initialize: function initialize(options) {
L$1.Util.setOptions(this, options);
this.options.markerOptions.clickable = false;
this.options.markerOptions.draggable = false;
},
buildSymbol: function buildSymbol(directionPoint, latLngs, map, index, total) {
if (this.options.rotate) {
this.options.markerOptions.rotationAngle = directionPoint.heading + (this.options.angleCorrection || 0);
}
return L$1.marker(directionPoint.latLng, this.options.markerOptions);
}
});
L$1.Symbol.marker = function (options) {
return new L$1.Symbol.Marker(options);
};
var isCoord = function isCoord(c) {
return c instanceof L$1.LatLng || Array.isArray(c) && c.length === 2 && typeof c[0] === 'number';
};
var isCoordArray = function isCoordArray(ll) {
return Array.isArray(ll) && isCoord(ll[0]);
};
L$1.PolylineDecorator = L$1.FeatureGroup.extend({
options: {
patterns: []
},
initialize: function initialize(paths, options) {
L$1.FeatureGroup.prototype.initialize.call(this);
L$1.Util.setOptions(this, options);
this._map = null;
this._paths = this._initPaths(paths);
this._bounds = this._initBounds();
this._patterns = this._initPatterns(this.options.patterns);
},
/**
* Deals with all the different cases. input can be one of these types:
* array of LatLng, array of 2-number arrays, Polyline, Polygon,
* array of one of the previous.
*/
_initPaths: function _initPaths(input, isPolygon) {
var _this = this;
if (isCoordArray(input)) {
// Leaflet Polygons don't need the first point to be repeated, but we do
var coords = isPolygon ? input.concat([input[0]]) : input;
return [coords];
}
if (input instanceof L$1.Polyline) {
// we need some recursivity to support multi-poly*
return this._initPaths(input.getLatLngs(), input instanceof L$1.Polygon);
}
if (Array.isArray(input)) {
// flatten everything, we just need coordinate lists to apply patterns
return input.reduce(function (flatArray, p) {
return flatArray.concat(_this._initPaths(p, isPolygon));
}, []);
}
return [];
},
// parse pattern definitions and precompute some values
_initPatterns: function _initPatterns(patternDefs) {
return patternDefs.map(this._parsePatternDef);
},
/**
* Changes the patterns used by this decorator
* and redraws the new one.
*/
setPatterns: function setPatterns(patterns) {
this.options.patterns = patterns;
this._patterns = this._initPatterns(this.options.patterns);
this.redraw();
},
/**
* Changes the patterns used by this decorator
* and redraws the new one.
*/
setPaths: function setPaths(paths) {
this._paths = this._initPaths(paths);
this._bounds = this._initBounds();
this.redraw();
},
/**
* Parse the pattern definition
*/
_parsePatternDef: function _parsePatternDef(patternDef, latLngs) {
return {
symbolFactory: patternDef.symbol,
// Parse offset and repeat values, managing the two cases:
// absolute (in pixels) or relative (in percentage of the polyline length)
offset: parseRelativeOrAbsoluteValue(patternDef.offset),
endOffset: parseRelativeOrAbsoluteValue(patternDef.endOffset),
repeat: parseRelativeOrAbsoluteValue(patternDef.repeat)
};
},
onAdd: function onAdd(map) {
this._map = map;
this._draw();
this._map.on('moveend', this.redraw, this);
},
onRemove: function onRemove(map) {
this._map.off('moveend', this.redraw, this);
this._map = null;
L$1.FeatureGroup.prototype.onRemove.call(this, map);
},
/**
* As real pattern bounds depends on map zoom and bounds,
* we just compute the total bounds of all paths decorated by this instance.
*/
_initBounds: function _initBounds() {
var allPathCoords = this._paths.reduce(function (acc, path) {
return acc.concat(path);
}, []);
return L$1.latLngBounds(allPathCoords);
},
getBounds: function getBounds() {
return this._bounds;
},
/**
* Returns an array of ILayers object
*/
_buildSymbols: function _buildSymbols(latLngs, symbolFactory, directionPoints) {
var _this2 = this;
return directionPoints.map(function (directionPoint, i) {
return symbolFactory.buildSymbol(directionPoint, latLngs, _this2._map, i, directionPoints.length);
});
},
/**
* Compute pairs of LatLng and heading angle,
* that define positions and directions of the symbols on the path
*/
_getDirectionPoints: function _getDirectionPoints(latLngs, pattern) {
var _this3 = this;
if (latLngs.length < 2) {
return [];
}
var pathAsPoints = latLngs.map(function (latLng) {
return _this3._map.project(latLng);
});
return projectPatternOnPointPath(pathAsPoints, pattern).map(function (point) {
return {
latLng: _this3._map.unproject(L$1.point(point.pt)),
heading: point.heading
};
});
},
redraw: function redraw() {
if (!this._map) {
return;
}
this.clearLayers();
this._draw();
},
/**
* Returns all symbols for a given pattern as an array of FeatureGroup
*/
_getPatternLayers: function _getPatternLayers(pattern) {
var _this4 = this;
var mapBounds = this._map.getBounds().pad(0.1);
return this._paths.map(function (path) {
var directionPoints = _this4._getDirectionPoints(path, pattern)
// filter out invisible points
.filter(function (point) {
return mapBounds.contains(point.latLng);
});
return L$1.featureGroup(_this4._buildSymbols(path, pattern.symbolFactory, directionPoints));
});
},
/**
* Draw all patterns
*/
_draw: function _draw() {
var _this5 = this;
this._patterns.map(function (pattern) {
return _this5._getPatternLayers(pattern);
}).forEach(function (layers) {
_this5.addLayer(L$1.featureGroup(layers));
});
}
});
/*
* Allows compact syntax to be used
*/
L$1.polylineDecorator = function (paths, options) {
return new L$1.PolylineDecorator(paths, options);
};
})));

View File

@@ -1227,6 +1227,11 @@ $(function() {
$('#refreshIcon').removeClass('fa-spinner fa-spin').addClass('fa-hourglass-half'); $('#refreshIcon').removeClass('fa-spinner fa-spin').addClass('fa-hourglass-half');
$('#refreshTimer').text('Next update in ' + refreshCountdown + 's'); $('#refreshTimer').text('Next update in ' + refreshCountdown + 's');
} }
// Update DX Map only if visible (don't waste resources)
if (dxMapVisible && dxMap) {
updateDxMap();
}
}, 100); }, 100);
} }
@@ -3328,6 +3333,884 @@ $(function() {
// Update ages every 60 seconds // Update ages every 60 seconds
setInterval(updateSpotAges, 60000); setInterval(updateSpotAges, 60000);
// ========================================
// DX MAP
// ========================================
let dxMap = null;
let dxMapVisible = false;
let dxccMarkers = [];
let spotterMarkers = [];
let connectionLines = [];
let userHomeMarker = null;
let showSpotters = false;
let hoverSpottersData = new Map(); // Store spotter data for hover
let hoverSpotterMarkers = []; // Temporary markers shown on hover
let hoverConnectionLines = []; // Temporary lines shown on hover
let hoverEventsInitialized = false; // Flag to prevent duplicate event handlers
/**
* Initialize the DX Map with Leaflet
*/
function initDxMap() {
if (dxMap) return;
dxMap = L.map('dxMap', {
center: [50.0647, 19.9450], // Krakow, Poland
zoom: 6,
zoomControl: true,
scrollWheelZoom: true,
fullscreenControl: true,
fullscreenControlOptions: {
position: 'topleft'
}
});
// Create custom panes for proper layering
dxMap.createPane('connectionLines');
dxMap.getPane('connectionLines').style.zIndex = 400;
dxMap.createPane('arrowsPane');
dxMap.getPane('arrowsPane').style.zIndex = 450;
L.tileLayer(map_tile_server || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: map_tile_server_copyright || '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 18,
minZoom: 1,
id: 'mapbox.streets'
}).addTo(dxMap);
addUserHomeMarker();
addSpottersControl();
}
/**
* Add user's home position marker
*/
function addUserHomeMarker() {
$.ajax({
url: base_url + 'index.php/logbook/qralatlngjson',
type: 'post',
data: { qra: user_gridsquare },
success: function(data) {
try {
const result = JSON.parse(data);
if (result && result[0] !== undefined && result[1] !== undefined) {
const homeIcon = L.icon({
iconUrl: icon_dot_url,
iconSize: [18, 18]
});
userHomeMarker = L.marker([result[0], result[1]], { icon: homeIcon })
.addTo(dxMap)
.bindPopup('<strong>' + lang_bandmap_your_qth + '</strong>');
}
} catch (e) {
console.warn('Could not parse user location:', e);
}
}
});
}
/**
* Add spotters control legend
*/
function addSpottersControl() {
const legend = L.control({ position: "topright" });
legend.onAdd = function(map) {
const div = L.DomUtil.create("div", "legend");
div.innerHTML = '<input type="checkbox" id="toggleSpotters" style="outline: none;"><span> ' + lang_bandmap_draw_spotters + '</span><br>';
return div;
};
legend.addTo(dxMap);
setTimeout(() => {
$('#toggleSpotters').on('change', function() {
showSpotters = this.checked;
updateDxMap();
});
}, 100);
}
/**
* Group spots by DXCC entity
*/
function groupSpotsByDXCC(spots) {
const dxccGroups = new Map();
spots.forEach(spot => {
const dxccId = spot.dxcc_spotted?.dxcc_id;
if (!dxccId) {
return;
}
if (!dxccGroups.has(dxccId)) {
dxccGroups.set(dxccId, {
dxccId: dxccId,
lat: spot.dxcc_spotted.lat,
lng: spot.dxcc_spotted.lng,
entity: spot.dxcc_spotted.entity,
flag: spot.dxcc_spotted.flag,
continent: spot.dxcc_spotted.cont,
spots: []
});
}
dxccGroups.get(dxccId).spots.push(spot);
});
return dxccGroups;
}
/**
* Create HTML table for popup
*/
function createSpotTable(spots, dxccEntity, dxccFlag) {
// Add DXCC name header with flag (bigger flag size)
const flagEmoji = dxccFlag ? '<span class="flag-emoji" style="font-size: 20px;">' + dxccFlag + '</span> ' : '';
let html = '<div style="font-weight: bold; font-size: 14px; padding: 8px; background: rgba(0,0,0,0.1); margin-bottom: 8px; text-align: center;">' + flagEmoji + dxccEntity + '</div>';
// Create scrollable container if more than 5 spots
const needsScroll = spots.length > 5;
if (needsScroll) {
html += '<div style="max-height: 200px; overflow-y: auto; overflow-x: hidden;">';
}
html += '<table class="table table-sm table-striped" style="margin: 0; width: 100%; table-layout: fixed;">';
html += '<thead><tr>';
html += '<th style="width: 25%; overflow: hidden; text-overflow: ellipsis;">' + lang_bandmap_callsign + '</th>';
html += '<th style="width: 20%; overflow: hidden; text-overflow: ellipsis;">' + lang_bandmap_frequency + '</th>';
html += '<th style="width: 15%; overflow: hidden; text-overflow: ellipsis;">' + lang_bandmap_mode + '</th>';
html += '<th style="width: 15%; overflow: hidden; text-overflow: ellipsis;">' + lang_bandmap_band + '</th>';
html += '<th style="width: 25%; overflow: hidden; text-overflow: ellipsis;">Spotter</th>';
html += '</tr></thead><tbody>';
spots.forEach(spot => {
const freqMHz = (spot.frequency / 1000).toFixed(3);
// Color code callsign based on worked/confirmed status (matching bandmap table)
let callClass = '';
if (spot.cnfmd_call) {
callClass = 'text-success'; // Green = confirmed
} else if (spot.worked_call) {
callClass = 'text-warning'; // Yellow = worked
}
html += '<tr>';
html += '<td style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"><strong><a href="#" class="spot-link ' + callClass + '" data-callsign="' + spot.spotted + '">' + spot.spotted + '</a></strong></td>';
html += '<td style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">' + freqMHz + '</td>';
html += '<td style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">' + (spot.mode || '') + '</td>';
html += '<td style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">' + (spot.band || '') + '</td>';
html += '<td style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">' + (spot.spotter || '') + '</td>';
html += '</tr>';
});
html += '</tbody></table>';
if (needsScroll) {
html += '</div>';
}
return html;
}
/**
* Get color based on mode (using DX waterfall colors)
*/
function getSpotModeColor(mode) {
const modeUpper = (mode || '').toUpperCase();
if (modeUpper === 'CW') return '#FFA500'; // Orange
if (['SSB', 'LSB', 'USB', 'AM', 'FM', 'PHONE'].includes(modeUpper)) return '#00FF00'; // Green
if (['FT8', 'FT4', 'RTTY', 'PSK', 'DIGITAL', 'DIGI'].some(m => modeUpper.includes(m))) return '#0096FF'; // Blue
return '#A020F0'; // Purple for other
}
/**
* Get border color based on continent status (matching bandmap table colors)
*/
function getContinentStatusColor(cnfmdContinent, workedContinent) {
// Green = confirmed, Yellow = worked (not confirmed), Red = new (not worked)
if (cnfmdContinent) {
return '#28a745'; // Bootstrap success green (confirmed)
} else if (workedContinent) {
return '#ffc107'; // Bootstrap warning yellow (worked but not confirmed)
}
return '#dc3545'; // Bootstrap danger red (new/not worked)
}
/**
* Get fill color based on DXCC status (matching bandmap table colors)
*/
function getDxccStatusColor(cnfmdDxcc, workedDxcc) {
// Green = confirmed, Yellow = worked (not confirmed), Red = new (not worked)
if (cnfmdDxcc) {
return '#28a745'; // Bootstrap success green (confirmed)
} else if (workedDxcc) {
return '#ffc107'; // Bootstrap warning yellow (worked but not confirmed)
}
return '#dc3545'; // Bootstrap danger red (new/not worked)
}
/**
* Scroll to spot in the main DataTable
*/
function scrollToSpotInTable(callsign) {
const table = get_dtable();
if (!table) return;
// Find row with matching callsign
const row = table.rows().nodes().toArray().find(node => {
const callsignCell = $(node).find('td:eq(4)').text();
return callsignCell.includes(callsign);
});
if (row) {
// Scroll to row
$('html, body').animate({
scrollTop: $(row).offset().top - 100
}, 500);
// Briefly highlight the row
$(row).addClass('table-active');
setTimeout(() => {
$(row).removeClass('table-active');
}, 2000);
}
}
/**
* Update DX Map with DXCC grouping
*/
function updateDxMap() {
if (!dxMap) {
console.log('DX Map: map not initialized');
return;
}
// Clear existing markers
dxccMarkers.forEach(marker => dxMap.removeLayer(marker));
spotterMarkers.forEach(marker => dxMap.removeLayer(marker));
connectionLines.forEach(line => dxMap.removeLayer(line));
dxccMarkers = [];
spotterMarkers = [];
connectionLines = [];
// Get filtered spots from DataTable
const table = get_dtable();
if (!table) {
return;
}
const filteredData = table.rows({ search: 'applied' }).data();
if (filteredData.length === 0) {
return;
}
// Build list of spots from filtered data
const spots = [];
filteredData.each(function(row) {
const freqMHzStr = row[2];
const freqKHz = parseFloat(freqMHzStr) * 1000;
const callsignHtml = row[4];
let callsign = null;
let match = callsignHtml.match(/db\/([^"]+)"/);
if (match) {
callsign = match[1];
} else {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = callsignHtml;
callsign = tempDiv.textContent.trim();
}
if (!callsign) return;
const spot = cachedSpotData.find(s =>
s.spotted === callsign &&
Math.abs(s.frequency - freqKHz) < 5
);
if (spot && spot.dxcc_spotted?.lat && spot.dxcc_spotted?.lng) {
spots.push(spot);
}
});
// Group by DXCC
const dxccGroups = groupSpotsByDXCC(spots);
// Clear hover data for new update
hoverSpottersData.clear();
// Create one marker per DXCC
const bounds = [];
let markersCreated = 0;
dxccGroups.forEach(dxccInfo => {
const lat = parseFloat(dxccInfo.lat);
const lng = parseFloat(dxccInfo.lng);
if (isNaN(lat) || isNaN(lng)) {
return;
}
const count = dxccInfo.spots.length;
const countText = count > 1 ? ` x${count}` : '';
// Derive a short prefix from the first callsign
const firstCall = dxccInfo.spots[0]?.spotted || '';
const prefix = firstCall.match(/^[A-Z0-9]{1,3}/)?.[0] || dxccInfo.entity.substring(0, 3).toUpperCase();
// Find the best (most optimistic) status in the group
// Priority: confirmed > worked > new
let bestContinentConfirmed = false;
let bestContinentWorked = false;
let bestDxccConfirmed = false;
let bestDxccWorked = false;
dxccInfo.spots.forEach(spot => {
// Check continent status
if (spot.cnfmd_continent) {
bestContinentConfirmed = true;
}
if (spot.worked_continent) {
bestContinentWorked = true;
}
// Check DXCC status
if (spot.cnfmd_dxcc) {
bestDxccConfirmed = true;
}
if (spot.worked_dxcc) {
bestDxccWorked = true;
}
});
const borderColor = getContinentStatusColor(bestContinentConfirmed, bestContinentWorked);
const fillColor = getDxccStatusColor(bestDxccConfirmed, bestDxccWorked);
const marker = L.marker([lat, lng], {
icon: L.divIcon({
className: 'dx-dxcc-marker',
html: `<div class="dx-marker-label" data-dxcc-id="${dxccInfo.dxccId}" style="text-align: center; font-size: 10px; font-weight: bold; color: #000; background: ${fillColor}; padding: 1px 4px; border-radius: 2px; border: 1px solid ${borderColor}; box-shadow: 0 1px 2px rgba(0,0,0,0.3); white-space: nowrap;">
${prefix}${countText}
</div>`,
iconSize: [45, 18],
iconAnchor: [22, 9]
})
});
// Store spotter data for this DXCC for hover functionality (incoming spots)
const spottersForThisDxcc = [];
dxccInfo.spots.forEach(spot => {
if (spot.dxcc_spotter?.dxcc_id && spot.dxcc_spotter.lat && spot.dxcc_spotter.lng) {
spottersForThisDxcc.push({
dxccId: spot.dxcc_spotter.dxcc_id,
lat: spot.dxcc_spotter.lat,
lng: spot.dxcc_spotter.lng,
entity: spot.dxcc_spotter.entity,
flag: spot.dxcc_spotter.flag,
continent: spot.dxcc_spotter.cont,
spotter: spot.spotter
});
}
});
// Store outgoing spots data (where this DXCC is the spotter)
const outgoingSpots = [];
spots.forEach(spot => {
if (spot.dxcc_spotter?.dxcc_id === dxccInfo.dxccId &&
spot.dxcc_spotted?.dxcc_id &&
spot.dxcc_spotted.lat &&
spot.dxcc_spotted.lng) {
outgoingSpots.push({
dxccId: spot.dxcc_spotted.dxcc_id,
lat: spot.dxcc_spotted.lat,
lng: spot.dxcc_spotted.lng,
entity: spot.dxcc_spotted.entity,
flag: spot.dxcc_spotted.flag,
continent: spot.dxcc_spotted.cont,
callsign: spot.callsign
});
}
});
hoverSpottersData.set(String(dxccInfo.dxccId), {
spotters: spottersForThisDxcc, // incoming (red)
outgoing: outgoingSpots, // outgoing (green)
targetLat: lat,
targetLng: lng,
targetContinent: dxccInfo.continent
});
marker.bindPopup(createSpotTable(dxccInfo.spots, dxccInfo.entity, dxccInfo.flag), {
maxWidth: 500,
minWidth: 350
});
marker.on('popupopen', function() {
// Add click handlers to callsign links after popup opens
setTimeout(() => {
document.querySelectorAll('.spot-link').forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const callsign = this.getAttribute('data-callsign');
scrollToSpotInTable(callsign);
});
});
}, 10);
});
marker.addTo(dxMap);
dxccMarkers.push(marker);
bounds.push([lat, lng]);
markersCreated++;
});
// Draw spotters if enabled
if (showSpotters) {
const spotterGroups = new Map();
const drawnConnections = new Set(); // Track drawn connections
spots.forEach(spot => {
const spotterId = spot.dxcc_spotter?.dxcc_id;
if (!spotterId) return;
if (!spotterGroups.has(spotterId)) {
spotterGroups.set(spotterId, {
lat: spot.dxcc_spotter.lat,
lng: spot.dxcc_spotter.lng,
entity: spot.dxcc_spotter.entity,
flag: spot.dxcc_spotter.flag,
continent: spot.dxcc_spotter.cont,
spotIds: new Set(),
callsigns: []
});
}
spotterGroups.get(spotterId).spotIds.add(spot.dxcc_spotted?.dxcc_id);
spotterGroups.get(spotterId).callsigns.push(spot.spotter);
});
// Detect bi-directional connections
const biDirectionalPairs = new Set();
spotterGroups.forEach((spotterInfo, spotterId) => {
spotterInfo.spotIds.forEach(spotId => {
const spottedGroup = spotterGroups.get(spotId);
if (spottedGroup && spottedGroup.spotIds.has(spotterId)) {
// Create consistent pair key (sorted to avoid duplicates)
const pairKey = [spotterId, spotId].sort().join('-');
biDirectionalPairs.add(pairKey);
}
});
});
if (biDirectionalPairs.size > 0) {
console.log(`Found ${biDirectionalPairs.size} bi-directional connections:`, Array.from(biDirectionalPairs));
}
// Draw blue dots for spotters (permanent connections shown in orange)
spotterGroups.forEach((spotterInfo, spotterId) => {
const lat = parseFloat(spotterInfo.lat);
const lng = parseFloat(spotterInfo.lng);
if (isNaN(lat) || isNaN(lng)) return;
const marker = L.circleMarker([lat, lng], {
radius: 5,
fillColor: '#ff9900',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
});
// Add tooltip showing spotter entity and count
const uniqueCallsigns = [...new Set(spotterInfo.callsigns)];
const spotterCount = uniqueCallsigns.length;
const tooltipText = `${spotterInfo.flag || ''} ${spotterInfo.entity}<br>${spotterCount} spotter${spotterCount !== 1 ? 's' : ''}`;
marker.bindTooltip(tooltipText, { permanent: false, direction: 'top' });
marker.addTo(dxMap);
spotterMarkers.push(marker);
// Draw lines to spotted DXCC entities (skip if same continent)
spotterInfo.spotIds.forEach(spotId => {
const dxccInfo = dxccGroups.get(spotId);
if (dxccInfo) {
// Skip line if both are in same continent
if (spotterInfo.continent && dxccInfo.continent &&
spotterInfo.continent === dxccInfo.continent) {
return;
}
const spotLat = parseFloat(dxccInfo.lat);
const spotLng = parseFloat(dxccInfo.lng);
if (!isNaN(spotLat) && !isNaN(spotLng)) {
// Check if this is a bi-directional connection
const pairKey = [spotterId, spotId].sort().join('-');
const isBiDirectional = biDirectionalPairs.has(pairKey);
// Only draw once for bi-directional pairs (using sorted key)
if (isBiDirectional && drawnConnections.has(pairKey)) {
return;
}
drawnConnections.add(pairKey);
// Create line with proper pane (orange for permanent spotters)
const line = L.polyline([[lat, lng], [spotLat, spotLng]], {
color: '#ff9900',
weight: 1,
opacity: 0.5,
dashArray: '5, 5',
pane: 'connectionLines'
});
line.addTo(dxMap);
connectionLines.push(line);
// Add arrow decorator(s) to show direction (spotter → spotted)
if (typeof L.polylineDecorator !== 'undefined') {
if (isBiDirectional) {
// Bi-directional: add two filled arrows pointing in opposite directions
const decorator = L.polylineDecorator(line, {
patterns: [
{
offset: '30%',
repeat: 0,
symbol: L.Symbol.arrowHead({
pixelSize: 10,
polygon: true,
pathOptions: {
fillColor: '#ff9900',
fillOpacity: 0.9,
color: '#cc6600',
weight: 1,
opacity: 1
}
})
},
{
offset: '70%',
repeat: 0,
symbol: L.Symbol.arrowHead({
pixelSize: 10,
polygon: true,
pathOptions: {
fillColor: '#ff9900',
fillOpacity: 0.9,
color: '#cc6600',
weight: 1,
opacity: 1
}
})
}
]
});
decorator.addTo(dxMap);
connectionLines.push(decorator);
} else {
// Uni-directional: single filled arrow
const decorator = L.polylineDecorator(line, {
patterns: [{
offset: '50%',
repeat: 0,
symbol: L.Symbol.arrowHead({
pixelSize: 10,
polygon: true,
pathOptions: {
fillColor: '#ff9900',
fillOpacity: 0.9,
color: '#cc6600',
weight: 1,
opacity: 1
}
})
}]
});
decorator.addTo(dxMap);
connectionLines.push(decorator);
}
}
}
}
});
});
}
// Set up hover event handlers only once
if (!hoverEventsInitialized) {
hoverEventsInitialized = true;
$(document).on('mouseenter', '.dx-marker-label', function() {
if (!dxMap) {
console.log('Hover: Map not initialized');
return;
}
// Clear any existing hover elements
hoverSpotterMarkers.forEach(marker => {
try { dxMap.removeLayer(marker); } catch(e) {}
});
hoverConnectionLines.forEach(line => {
try { dxMap.removeLayer(line); } catch(e) {}
});
hoverSpotterMarkers = [];
hoverConnectionLines = [];
const dxccId = String($(this).data('dxcc-id'));
if (!dxccId || dxccId === 'undefined') {
console.log('Hover: No dxccId found');
return;
}
const hoverData = hoverSpottersData.get(dxccId);
if (!hoverData) {
console.log('Hover: No hover data for', dxccId);
return;
}
// Group incoming spotters by their DXCC to avoid duplicate lines
const spotterMap = new Map();
if (hoverData.spotters && hoverData.spotters.length > 0) {
hoverData.spotters.forEach(spotter => {
if (!spotterMap.has(spotter.dxccId)) {
spotterMap.set(spotter.dxccId, {
lat: spotter.lat,
lng: spotter.lng,
entity: spotter.entity,
flag: spotter.flag,
continent: spotter.continent,
callsigns: []
});
}
spotterMap.get(spotter.dxccId).callsigns.push(spotter.spotter);
});
}
// Group outgoing spots by their DXCC
const outgoingMap = new Map();
if (hoverData.outgoing && hoverData.outgoing.length > 0) {
hoverData.outgoing.forEach(spotted => {
if (!outgoingMap.has(spotted.dxccId)) {
outgoingMap.set(spotted.dxccId, {
lat: spotted.lat,
lng: spotted.lng,
entity: spotted.entity,
flag: spotted.flag,
continent: spotted.continent,
callsigns: []
});
}
outgoingMap.get(spotted.dxccId).callsigns.push(spotted.callsign);
});
}
// Use requestAnimationFrame for smooth rendering
requestAnimationFrame(() => {
// Draw incoming spotter markers and lines (RED)
spotterMap.forEach((spotterInfo) => {
const lat = parseFloat(spotterInfo.lat);
const lng = parseFloat(spotterInfo.lng);
if (isNaN(lat) || isNaN(lng)) return;
try {
const marker = L.circleMarker([lat, lng], {
radius: 5,
fillColor: '#ff0000',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
});
const uniqueCallsigns = [...new Set(spotterInfo.callsigns)];
const spotterCount = uniqueCallsigns.length;
const tooltipText = `${spotterInfo.flag || ''} ${spotterInfo.entity}<br>${spotterCount} spotter${spotterCount !== 1 ? 's' : ''}<br>→ Incoming`;
marker.bindTooltip(tooltipText, { permanent: false, direction: 'top' });
marker.addTo(dxMap);
hoverSpotterMarkers.push(marker);
// Draw RED line (incoming: spotter → target)
const line = L.polyline([[lat, lng], [hoverData.targetLat, hoverData.targetLng]], {
color: '#ff0000',
weight: 2,
opacity: 0.7,
dashArray: '5, 5',
pane: 'connectionLines'
});
line.addTo(dxMap);
hoverConnectionLines.push(line);
// Add arrow decorator to show direction (spotter → spotted)
if (L.polylineDecorator) {
const decorator = L.polylineDecorator(line, {
patterns: [{
offset: '50%',
repeat: 0,
symbol: L.Symbol.arrowHead({
pixelSize: 10,
polygon: true,
pathOptions: {
fillColor: '#ff0000',
fillOpacity: 0.9,
color: '#990000',
weight: 1,
opacity: 1
}
})
}]
});
decorator.addTo(dxMap);
hoverConnectionLines.push(decorator);
}
} catch(e) {
console.error('Error drawing incoming hover spotter:', e);
}
});
// Draw outgoing spot markers and lines (GREEN)
outgoingMap.forEach((spottedInfo) => {
const lat = parseFloat(spottedInfo.lat);
const lng = parseFloat(spottedInfo.lng);
if (isNaN(lat) || isNaN(lng)) return;
try {
const marker = L.circleMarker([lat, lng], {
radius: 5,
fillColor: '#00ff00',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
});
const uniqueCallsigns = [...new Set(spottedInfo.callsigns)];
const spotCount = uniqueCallsigns.length;
const tooltipText = `${spottedInfo.flag || ''} ${spottedInfo.entity}<br>${spotCount} spot${spotCount !== 1 ? 's' : ''}<br>← Outgoing`;
marker.bindTooltip(tooltipText, { permanent: false, direction: 'top' });
marker.addTo(dxMap);
hoverSpotterMarkers.push(marker);
// Draw GREEN line (outgoing: target → spotted)
const line = L.polyline([[hoverData.targetLat, hoverData.targetLng], [lat, lng]], {
color: '#00ff00',
weight: 2,
opacity: 0.7,
dashArray: '5, 5',
pane: 'connectionLines'
});
line.addTo(dxMap);
hoverConnectionLines.push(line);
// Add arrow decorator to show direction (target → spotted)
if (L.polylineDecorator) {
const decorator = L.polylineDecorator(line, {
patterns: [{
offset: '50%',
repeat: 0,
symbol: L.Symbol.arrowHead({
pixelSize: 10,
polygon: true,
pathOptions: {
fillColor: '#00ff00',
fillOpacity: 0.9,
color: '#009900',
weight: 1,
opacity: 1
}
})
}]
});
decorator.addTo(dxMap);
hoverConnectionLines.push(decorator);
}
} catch(e) {
console.error('Error drawing outgoing hover spot:', e);
}
});
});
});
$(document).on('mouseleave', '.dx-marker-label', function() {
if (!dxMap) return;
// Use requestAnimationFrame for smooth cleanup
requestAnimationFrame(() => {
// Remove hover spotters and lines
hoverSpotterMarkers.forEach(marker => {
try { dxMap.removeLayer(marker); } catch(e) {}
});
hoverConnectionLines.forEach(line => {
try { dxMap.removeLayer(line); } catch(e) {}
});
hoverSpotterMarkers = [];
hoverConnectionLines = [];
});
});
}
// Fit bounds
if (bounds.length > 0) {
dxMap.fitBounds(bounds, { padding: [50, 50], maxZoom: 8 });
}
setTimeout(() => {
if (dxMap) dxMap.invalidateSize();
}, 100);
}
/**
* Toggle DX Map visibility
*/
$('#dxMapButton').on('click', function() {
const container = $('#dxMapContainer');
if (dxMapVisible) {
// Hide map
container.slideUp(300);
dxMapVisible = false;
$(this).removeClass('btn-success').addClass('btn-primary');
} else {
// Show map
if (!dxMap) {
initDxMap();
}
container.slideDown(300, function() {
updateDxMap();
// After first show, wait 1 second and reset zoom/viewport
setTimeout(() => {
if (dxMap) {
const table = get_dtable();
if (table) {
const filteredData = table.rows({ search: 'applied' }).data();
if (filteredData.length > 0) {
// Collect bounds from all visible markers
const mapBounds = [];
dxccMarkers.forEach(marker => {
const latLng = marker.getLatLng();
if (latLng) mapBounds.push([latLng.lat, latLng.lng]);
});
if (mapBounds.length > 0) {
dxMap.fitBounds(mapBounds, { padding: [50, 50], maxZoom: 8 });
}
}
}
}
}, 1000);
});
dxMapVisible = true;
$(this).removeClass('btn-primary').addClass('btn-success');
}
});
// Update map when filters change (if map is visible)
const originalApplyFilters = applyFilters;
applyFilters = function(forceReload = false) {
originalApplyFilters(forceReload);
// Only update map if it's visible - don't waste resources
if (dxMapVisible && dxMap) {
setTimeout(updateDxMap, 500);
}
};
}); });