mirror of
https://github.com/wavelog/wavelog.git
synced 2026-03-22 02:14:13 +00:00
DX Map
This commit is contained in:
@@ -83,8 +83,32 @@
|
||||
var lang_bandmap_no_spots_filters = "<?= __("No spots found for selected filters"); ?>";
|
||||
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
|
||||
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>
|
||||
|
||||
<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"); ?>">
|
||||
<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>
|
||||
<h5 class="mb-0"><?= __("DX Cluster - spot list"); ?></h5>
|
||||
<h5 class="mb-0"><?= __("DX Cluster"); ?></h5>
|
||||
</div>
|
||||
<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>
|
||||
@@ -391,12 +415,19 @@
|
||||
<i class="fas fa-bolt"></i> <span class="d-none d-sm-inline"><?= __("Fresh"); ?></span>
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<!-- Row 5: Status Bar (70%) and Search (30%) -->
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
||||
<!-- 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-inner">
|
||||
<div class="status-bar-left">
|
||||
@@ -411,13 +442,21 @@
|
||||
</div>
|
||||
|
||||
<!-- 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"); ?>">
|
||||
<span class="input-group-text search-icon-clickable" id="searchIcon"><i class="fas fa-search"></i></span>
|
||||
</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">
|
||||
<table class="table table-striped table-hover spottable">
|
||||
<thead>
|
||||
|
||||
@@ -1466,6 +1466,7 @@ mymap.on('mousemove', onQsoMapMove);
|
||||
<!--- Bandmap CAT Integration --->
|
||||
<?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/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>
|
||||
<?php } ?>
|
||||
|
||||
|
||||
@@ -282,14 +282,14 @@
|
||||
<li class="nav-item dropdown"> <!-- TOOLS -->
|
||||
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#"><?= __("Tools"); ?></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
478
assets/js/leaflet.polylineDecorator.js
Normal file
478
assets/js/leaflet.polylineDecorator.js
Normal 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);
|
||||
};
|
||||
|
||||
})));
|
||||
@@ -1227,6 +1227,11 @@ $(function() {
|
||||
$('#refreshIcon').removeClass('fa-spinner fa-spin').addClass('fa-hourglass-half');
|
||||
$('#refreshTimer').text('Next update in ' + refreshCountdown + 's');
|
||||
}
|
||||
|
||||
// Update DX Map only if visible (don't waste resources)
|
||||
if (dxMapVisible && dxMap) {
|
||||
updateDxMap();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
@@ -3328,6 +3333,884 @@ $(function() {
|
||||
// Update ages every 60 seconds
|
||||
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 || '© <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);
|
||||
}
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user